@haus-tech/haus-workflow 1.4.1 → 1.5.1
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 +44 -0
- package/dist/cli.js +2624 -538
- package/library/catalog/manifest.json +16 -16
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -59,12 +59,12 @@ var init_exec = __esm({
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// src/cli.ts
|
|
62
|
-
import { readFileSync as
|
|
63
|
-
import
|
|
62
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
63
|
+
import path71 from "node:path";
|
|
64
64
|
import { Command } from "commander";
|
|
65
65
|
|
|
66
66
|
// src/commands/apply.ts
|
|
67
|
-
import
|
|
67
|
+
import path23 from "node:path";
|
|
68
68
|
import checkbox from "@inquirer/checkbox";
|
|
69
69
|
|
|
70
70
|
// src/catalog/load-catalog.ts
|
|
@@ -141,13 +141,44 @@ async function atomicWriteText(file, value) {
|
|
|
141
141
|
await fs.writeFile(tempPath, value, "utf8");
|
|
142
142
|
await fs.move(tempPath, file, { overwrite: true });
|
|
143
143
|
}
|
|
144
|
+
async function findNestedRepoDirs(root) {
|
|
145
|
+
const matches = await fg("**/.git", {
|
|
146
|
+
cwd: root,
|
|
147
|
+
dot: true,
|
|
148
|
+
onlyFiles: false,
|
|
149
|
+
followSymbolicLinks: false,
|
|
150
|
+
suppressErrors: true,
|
|
151
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
152
|
+
});
|
|
153
|
+
const owners = /* @__PURE__ */ new Set();
|
|
154
|
+
for (const match of matches) {
|
|
155
|
+
const owner = path.posix.dirname(match);
|
|
156
|
+
if (owner === "." || owner === "") continue;
|
|
157
|
+
owners.add(owner);
|
|
158
|
+
}
|
|
159
|
+
const shallowestFirst = [...owners].sort((a, b) => a.split("/").length - b.split("/").length);
|
|
160
|
+
const nested = [];
|
|
161
|
+
for (const dir of shallowestFirst) {
|
|
162
|
+
if (nested.some((kept) => dir === kept || dir.startsWith(`${kept}/`))) continue;
|
|
163
|
+
nested.push(dir);
|
|
164
|
+
}
|
|
165
|
+
return nested;
|
|
166
|
+
}
|
|
144
167
|
async function listFiles(root, patterns) {
|
|
168
|
+
const nestedRepoDirs = await findNestedRepoDirs(root);
|
|
169
|
+
const ignore = [
|
|
170
|
+
"**/node_modules/**",
|
|
171
|
+
"**/.git/**",
|
|
172
|
+
"**/dist/**",
|
|
173
|
+
"**/tests/fixtures/**",
|
|
174
|
+
...nestedRepoDirs.map((d) => `${d}/**`)
|
|
175
|
+
];
|
|
145
176
|
const files = await fg(patterns, {
|
|
146
177
|
cwd: root,
|
|
147
178
|
dot: true,
|
|
148
179
|
onlyFiles: true,
|
|
149
180
|
suppressErrors: true,
|
|
150
|
-
ignore
|
|
181
|
+
ignore
|
|
151
182
|
});
|
|
152
183
|
return files.sort((a, b) => a.localeCompare(b));
|
|
153
184
|
}
|
|
@@ -478,12 +509,12 @@ async function resolveCatalogRef(opts) {
|
|
|
478
509
|
const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
|
|
479
510
|
const tag = await fetchLatest();
|
|
480
511
|
if (tag !== null) return tag;
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
512
|
+
const fallback2 = opts?.fallbackRef;
|
|
513
|
+
if (fallback2) {
|
|
483
514
|
warn(
|
|
484
|
-
`Tag resolution failed \u2014 using cached ref ${
|
|
515
|
+
`Tag resolution failed \u2014 using cached ref ${fallback2}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
485
516
|
);
|
|
486
|
-
return
|
|
517
|
+
return fallback2;
|
|
487
518
|
}
|
|
488
519
|
const bundled = getBundledCatalogRef();
|
|
489
520
|
if (bundled) {
|
|
@@ -1489,6 +1520,37 @@ function catalogItemContentPath(contentRoot, item) {
|
|
|
1489
1520
|
return path9.join(contentRoot, item.path);
|
|
1490
1521
|
}
|
|
1491
1522
|
|
|
1523
|
+
// src/claude/gitignore-owned-check.ts
|
|
1524
|
+
init_exec();
|
|
1525
|
+
var HAUS_OWNED_TRACKED_PATHS = [
|
|
1526
|
+
".claude",
|
|
1527
|
+
".claude/skills",
|
|
1528
|
+
".claude/agents",
|
|
1529
|
+
".claude/commands",
|
|
1530
|
+
".haus-workflow"
|
|
1531
|
+
];
|
|
1532
|
+
async function findGitignoredHausPaths(root) {
|
|
1533
|
+
try {
|
|
1534
|
+
const result = await runGit(["check-ignore", "--", ...HAUS_OWNED_TRACKED_PATHS], {
|
|
1535
|
+
cwd: root,
|
|
1536
|
+
timeout: 5e3
|
|
1537
|
+
});
|
|
1538
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) return [];
|
|
1539
|
+
return [
|
|
1540
|
+
...new Set(
|
|
1541
|
+
result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
|
|
1542
|
+
)
|
|
1543
|
+
];
|
|
1544
|
+
} catch {
|
|
1545
|
+
return [];
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
function summarizeGitignoredHausDirs(paths) {
|
|
1549
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1550
|
+
for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
|
|
1551
|
+
return [...dirs];
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1492
1554
|
// src/claude/write-claude-files.ts
|
|
1493
1555
|
import path19 from "node:path";
|
|
1494
1556
|
import fs18 from "fs-extra";
|
|
@@ -1596,7 +1658,8 @@ function digestFileContent(buf) {
|
|
|
1596
1658
|
}
|
|
1597
1659
|
return hashText(buf);
|
|
1598
1660
|
}
|
|
1599
|
-
async function hashInstalledPaths(root, relPaths) {
|
|
1661
|
+
async function hashInstalledPaths(root, relPaths, opts = {}) {
|
|
1662
|
+
const followSymlinks = opts.followSymlinks ?? true;
|
|
1600
1663
|
if (relPaths.length === 0) {
|
|
1601
1664
|
return hashText(EMPTY_LOCK_PATHS_TOKEN);
|
|
1602
1665
|
}
|
|
@@ -1605,17 +1668,24 @@ async function hashInstalledPaths(root, relPaths) {
|
|
|
1605
1668
|
for (const rel of normalized) {
|
|
1606
1669
|
const abs = path10.join(root, rel);
|
|
1607
1670
|
if (!await fs7.pathExists(abs)) continue;
|
|
1608
|
-
const stat = await fs7.stat(abs);
|
|
1671
|
+
const stat = followSymlinks ? await fs7.stat(abs) : await fs7.lstat(abs);
|
|
1672
|
+
if (!followSymlinks && stat.isSymbolicLink()) continue;
|
|
1609
1673
|
if (stat.isFile()) {
|
|
1610
1674
|
const body = await fs7.readFile(abs);
|
|
1611
1675
|
fileDigests.push({ rel, digest: digestFileContent(body) });
|
|
1612
1676
|
continue;
|
|
1613
1677
|
}
|
|
1614
1678
|
if (!stat.isDirectory()) continue;
|
|
1615
|
-
const inner = await fg2("**/*", {
|
|
1679
|
+
const inner = await fg2("**/*", {
|
|
1680
|
+
cwd: abs,
|
|
1681
|
+
onlyFiles: true,
|
|
1682
|
+
dot: true,
|
|
1683
|
+
followSymbolicLinks: followSymlinks
|
|
1684
|
+
});
|
|
1616
1685
|
for (const sub of inner.sort()) {
|
|
1617
|
-
const relFile = path10.join(rel, sub).replace(/\\/g, "/");
|
|
1618
1686
|
const absFile = path10.join(abs, sub);
|
|
1687
|
+
if (!followSymlinks && (await fs7.lstat(absFile)).isSymbolicLink()) continue;
|
|
1688
|
+
const relFile = path10.join(rel, sub).replace(/\\/g, "/");
|
|
1619
1689
|
const body = await fs7.readFile(absFile);
|
|
1620
1690
|
fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
|
|
1621
1691
|
}
|
|
@@ -2033,8 +2103,8 @@ function buildDenyRules() {
|
|
|
2033
2103
|
for (const command of DENY_COMMANDS) {
|
|
2034
2104
|
rules.push(`Bash(${command}:*)`);
|
|
2035
2105
|
}
|
|
2036
|
-
for (const
|
|
2037
|
-
const pattern = DENY_DIRS.has(
|
|
2106
|
+
for (const path72 of DENY_PATHS) {
|
|
2107
|
+
const pattern = DENY_DIRS.has(path72) ? `${path72}/**` : path72;
|
|
2038
2108
|
for (const tool of FILE_TOOLS) {
|
|
2039
2109
|
rules.push(`${tool}(${pattern})`);
|
|
2040
2110
|
}
|
|
@@ -2078,6 +2148,13 @@ var PROJECT_HOOK_FRAGMENTS = [
|
|
|
2078
2148
|
event: "SessionStart",
|
|
2079
2149
|
matcher: "*",
|
|
2080
2150
|
command: "haus update --from-hook"
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
id: "hook.workspace.worktree-check",
|
|
2154
|
+
gate: "keep",
|
|
2155
|
+
event: "SessionStart",
|
|
2156
|
+
matcher: "*",
|
|
2157
|
+
command: "haus workspace worktree doctor --from-hook"
|
|
2081
2158
|
}
|
|
2082
2159
|
];
|
|
2083
2160
|
async function readProjectSettings(root) {
|
|
@@ -2186,6 +2263,10 @@ var CANONICAL_HOOKS = {
|
|
|
2186
2263
|
{
|
|
2187
2264
|
matcher: "*",
|
|
2188
2265
|
hooks: [{ type: "command", command: "haus update --from-hook" }]
|
|
2266
|
+
},
|
|
2267
|
+
{
|
|
2268
|
+
matcher: "*",
|
|
2269
|
+
hooks: [{ type: "command", command: "haus workspace worktree doctor --from-hook" }]
|
|
2189
2270
|
}
|
|
2190
2271
|
]
|
|
2191
2272
|
}
|
|
@@ -2555,7 +2636,6 @@ function refillContent(existing, v) {
|
|
|
2555
2636
|
}
|
|
2556
2637
|
var FALLBACK_CONTEXT = {
|
|
2557
2638
|
generatedAt: "",
|
|
2558
|
-
root: "",
|
|
2559
2639
|
repoName: "",
|
|
2560
2640
|
packageManager: "unknown",
|
|
2561
2641
|
repoRoles: [],
|
|
@@ -3247,27 +3327,192 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
|
|
|
3247
3327
|
}
|
|
3248
3328
|
}
|
|
3249
3329
|
|
|
3330
|
+
// src/claude/write-gitignore.ts
|
|
3331
|
+
init_exec();
|
|
3332
|
+
import path20 from "node:path";
|
|
3333
|
+
import fs19 from "fs-extra";
|
|
3334
|
+
var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
3335
|
+
var GITIGNORE_END = "# HAUS:END haus-managed";
|
|
3336
|
+
var GITIGNORED_ARTIFACT_PATHS = [
|
|
3337
|
+
".haus-workflow/context-map.json",
|
|
3338
|
+
".haus-workflow/recommendation.json",
|
|
3339
|
+
".haus-workflow/sources-report.json",
|
|
3340
|
+
".haus-workflow/deep-context.json"
|
|
3341
|
+
];
|
|
3342
|
+
function findLineMarker3(content2, marker, from = 0) {
|
|
3343
|
+
let idx = content2.indexOf(marker, from);
|
|
3344
|
+
while (idx !== -1) {
|
|
3345
|
+
const lineStart = idx === 0 || content2[idx - 1] === "\n";
|
|
3346
|
+
const after = idx + marker.length;
|
|
3347
|
+
const lineEnd = after === content2.length || content2[after] === "\n" || content2[after] === "\r";
|
|
3348
|
+
if (lineStart && lineEnd) return { start: idx, end: after };
|
|
3349
|
+
idx = content2.indexOf(marker, idx + marker.length);
|
|
3350
|
+
}
|
|
3351
|
+
return null;
|
|
3352
|
+
}
|
|
3353
|
+
function findBlockRangeFor(content2, beginMarker, endMarker) {
|
|
3354
|
+
const begin = findLineMarker3(content2, beginMarker);
|
|
3355
|
+
if (!begin) return null;
|
|
3356
|
+
const end = findLineMarker3(content2, endMarker, begin.end);
|
|
3357
|
+
if (!end) return null;
|
|
3358
|
+
return { start: begin.start, end: end.end };
|
|
3359
|
+
}
|
|
3360
|
+
function gitignoreCovers(lines, entry) {
|
|
3361
|
+
const bare = entry.replace(/^\/+/, "");
|
|
3362
|
+
const exactEquivalents = /* @__PURE__ */ new Set([bare, `/${bare}`]);
|
|
3363
|
+
for (const raw of lines) {
|
|
3364
|
+
const line2 = raw.trim();
|
|
3365
|
+
if (!line2 || line2.startsWith("#")) continue;
|
|
3366
|
+
if (exactEquivalents.has(line2)) return true;
|
|
3367
|
+
const dirCandidate = line2.replace(/^\/+/, "").replace(/\/\*\*$/, "").replace(/\/$/, "");
|
|
3368
|
+
if (dirCandidate && !/[*?[\]!]/.test(dirCandidate)) {
|
|
3369
|
+
if (bare === dirCandidate || bare.startsWith(`${dirCandidate}/`)) return true;
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
return false;
|
|
3373
|
+
}
|
|
3374
|
+
function linesOutsideBlockFor(content2, beginMarker, endMarker) {
|
|
3375
|
+
const range = findBlockRangeFor(content2, beginMarker, endMarker);
|
|
3376
|
+
const outside = range ? content2.slice(0, range.start) + content2.slice(range.end) : content2;
|
|
3377
|
+
return outside.split("\n");
|
|
3378
|
+
}
|
|
3379
|
+
function buildBlockFor(existing, beginMarker, endMarker, comment, patterns) {
|
|
3380
|
+
const outside = linesOutsideBlockFor(existing, beginMarker, endMarker);
|
|
3381
|
+
const needed = patterns.filter((p) => !gitignoreCovers(outside, p));
|
|
3382
|
+
if (needed.length === 0) return null;
|
|
3383
|
+
return [beginMarker, comment, ...needed, endMarker].join("\n");
|
|
3384
|
+
}
|
|
3385
|
+
var ARTIFACT_BLOCK_COMMENT = "# haus scan artifacts \u2014 machine-local, must never be committed (see ADR-0025)";
|
|
3386
|
+
function injectBlockFor(existing, beginMarker, endMarker, comment, patterns) {
|
|
3387
|
+
const block = buildBlockFor(existing, beginMarker, endMarker, comment, patterns);
|
|
3388
|
+
const range = findBlockRangeFor(existing, beginMarker, endMarker);
|
|
3389
|
+
if (block === null) {
|
|
3390
|
+
if (!range) return existing;
|
|
3391
|
+
const before = existing.slice(0, range.start);
|
|
3392
|
+
const after = existing.slice(range.end);
|
|
3393
|
+
const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
3394
|
+
return merged.length > 0 ? `${merged}
|
|
3395
|
+
` : "";
|
|
3396
|
+
}
|
|
3397
|
+
if (range) {
|
|
3398
|
+
const before = existing.slice(0, range.start);
|
|
3399
|
+
const after = existing.slice(range.end);
|
|
3400
|
+
return `${before}${block}${after}`.replace(/\n*$/, "\n");
|
|
3401
|
+
}
|
|
3402
|
+
const loneBegin = findLineMarker3(existing, beginMarker);
|
|
3403
|
+
if (loneBegin) {
|
|
3404
|
+
const before = existing.slice(0, loneBegin.start).trimEnd();
|
|
3405
|
+
if (before.length === 0) return `${block}
|
|
3406
|
+
`;
|
|
3407
|
+
return `${before}
|
|
3408
|
+
|
|
3409
|
+
${block}
|
|
3410
|
+
`;
|
|
3411
|
+
}
|
|
3412
|
+
const trimmed = existing.trimEnd();
|
|
3413
|
+
if (trimmed.length === 0) return `${block}
|
|
3414
|
+
`;
|
|
3415
|
+
return `${trimmed}
|
|
3416
|
+
|
|
3417
|
+
${block}
|
|
3418
|
+
`;
|
|
3419
|
+
}
|
|
3420
|
+
function injectGitignoreBlock(existing) {
|
|
3421
|
+
return injectBlockFor(
|
|
3422
|
+
existing,
|
|
3423
|
+
GITIGNORE_BEGIN,
|
|
3424
|
+
GITIGNORE_END,
|
|
3425
|
+
ARTIFACT_BLOCK_COMMENT,
|
|
3426
|
+
GITIGNORED_ARTIFACT_PATHS
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
var LINK_CONTEXT_GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed-link-context v=1";
|
|
3430
|
+
var LINK_CONTEXT_GITIGNORE_END = "# HAUS:END haus-managed-link-context";
|
|
3431
|
+
var LINKED_CONTEXT_GITIGNORE_PATTERNS = [
|
|
3432
|
+
".claude/skills/*--*/",
|
|
3433
|
+
".claude/agents/*--*.md",
|
|
3434
|
+
".claude/commands/*--*.md"
|
|
3435
|
+
];
|
|
3436
|
+
var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies \u2014 derived cross-repo skill/agent/command copies, never committed";
|
|
3437
|
+
function injectLinkContextGitignoreBlock(existing) {
|
|
3438
|
+
return injectBlockFor(
|
|
3439
|
+
existing,
|
|
3440
|
+
LINK_CONTEXT_GITIGNORE_BEGIN,
|
|
3441
|
+
LINK_CONTEXT_GITIGNORE_END,
|
|
3442
|
+
LINK_CONTEXT_BLOCK_COMMENT,
|
|
3443
|
+
LINKED_CONTEXT_GITIGNORE_PATTERNS
|
|
3444
|
+
);
|
|
3445
|
+
}
|
|
3446
|
+
async function writeGitignore(root, dryRun) {
|
|
3447
|
+
const filePath = path20.join(root, ".gitignore");
|
|
3448
|
+
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
3449
|
+
const next = injectGitignoreBlock(prev);
|
|
3450
|
+
await writeManagedText(root, filePath, next, dryRun);
|
|
3451
|
+
return filePath;
|
|
3452
|
+
}
|
|
3453
|
+
async function writeLinkContextGitignore(root, dryRun) {
|
|
3454
|
+
const filePath = path20.join(root, ".gitignore");
|
|
3455
|
+
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
3456
|
+
const next = injectLinkContextGitignoreBlock(prev);
|
|
3457
|
+
await writeManagedText(root, filePath, next, dryRun);
|
|
3458
|
+
return filePath;
|
|
3459
|
+
}
|
|
3460
|
+
async function listTrackedArtifactPaths(root) {
|
|
3461
|
+
try {
|
|
3462
|
+
const result = await runGit(["ls-files", "--", ...GITIGNORED_ARTIFACT_PATHS], {
|
|
3463
|
+
cwd: root,
|
|
3464
|
+
timeout: 5e3
|
|
3465
|
+
});
|
|
3466
|
+
if (result.exitCode !== 0) return [];
|
|
3467
|
+
return result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean);
|
|
3468
|
+
} catch {
|
|
3469
|
+
return [];
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
async function untrackMachineLocalArtifacts(root, dryRun, notify) {
|
|
3473
|
+
const tracked = await listTrackedArtifactPaths(root);
|
|
3474
|
+
const untracked = [];
|
|
3475
|
+
for (const rel of tracked) {
|
|
3476
|
+
if (dryRun) {
|
|
3477
|
+
notify(
|
|
3478
|
+
`Would untrack ${rel} from git \u2014 it contains machine-local scan output and should never have been committed.`
|
|
3479
|
+
);
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
const result = await runGit(["rm", "--cached", "--quiet", "--", rel], { cwd: root });
|
|
3483
|
+
if (result.exitCode === 0) {
|
|
3484
|
+
untracked.push(rel);
|
|
3485
|
+
notify(
|
|
3486
|
+
`Untracked ${rel} from git \u2014 it contains machine-local scan output and should never have been committed. Run \`git status\` to review before committing this change.`
|
|
3487
|
+
);
|
|
3488
|
+
} else {
|
|
3489
|
+
notify(`Could not untrack ${rel} from git: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
return untracked;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3250
3495
|
// src/refs/fetch-refs.ts
|
|
3251
|
-
import
|
|
3252
|
-
import
|
|
3496
|
+
import path22 from "node:path";
|
|
3497
|
+
import fs21 from "fs-extra";
|
|
3253
3498
|
|
|
3254
3499
|
// src/refs/cache-meta.ts
|
|
3255
|
-
import
|
|
3256
|
-
import
|
|
3500
|
+
import path21 from "node:path";
|
|
3501
|
+
import fs20 from "fs-extra";
|
|
3257
3502
|
var META_FILENAME = "cache-meta.json";
|
|
3258
3503
|
async function readCacheMeta(cacheDir) {
|
|
3259
|
-
const metaPath =
|
|
3504
|
+
const metaPath = path21.join(cacheDir, META_FILENAME);
|
|
3260
3505
|
try {
|
|
3261
|
-
const raw = await
|
|
3506
|
+
const raw = await fs20.readFile(metaPath, "utf8");
|
|
3262
3507
|
return JSON.parse(raw);
|
|
3263
3508
|
} catch {
|
|
3264
3509
|
return {};
|
|
3265
3510
|
}
|
|
3266
3511
|
}
|
|
3267
3512
|
async function writeCacheMeta(cacheDir, meta) {
|
|
3268
|
-
await
|
|
3269
|
-
const metaPath =
|
|
3270
|
-
await
|
|
3513
|
+
await fs20.ensureDir(cacheDir);
|
|
3514
|
+
const metaPath = path21.join(cacheDir, META_FILENAME);
|
|
3515
|
+
await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
3271
3516
|
}
|
|
3272
3517
|
|
|
3273
3518
|
// src/refs/fetch-refs.ts
|
|
@@ -3287,8 +3532,8 @@ function isLlmsTxtUrl(ref) {
|
|
|
3287
3532
|
async function fetchSingleRef(url, cacheDir, meta) {
|
|
3288
3533
|
const existing = meta[url];
|
|
3289
3534
|
const headers = {};
|
|
3290
|
-
const safeFile = existing?.file && existing.file ===
|
|
3291
|
-
const cachedFileExists = safeFile && await
|
|
3535
|
+
const safeFile = existing?.file && existing.file === path22.basename(existing.file);
|
|
3536
|
+
const cachedFileExists = safeFile && await fs21.pathExists(path22.join(cacheDir, existing.file));
|
|
3292
3537
|
if (cachedFileExists) {
|
|
3293
3538
|
if (existing?.etag) headers["If-None-Match"] = existing.etag;
|
|
3294
3539
|
if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
|
|
@@ -3299,8 +3544,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
|
|
|
3299
3544
|
if (!res.ok) return { result: "failed" };
|
|
3300
3545
|
const text = await res.text();
|
|
3301
3546
|
const file = `${urlToSlug(url)}.md`;
|
|
3302
|
-
await
|
|
3303
|
-
await
|
|
3547
|
+
await fs21.ensureDir(cacheDir);
|
|
3548
|
+
await fs21.writeFile(path22.join(cacheDir, file), text, "utf8");
|
|
3304
3549
|
meta[url] = {
|
|
3305
3550
|
url,
|
|
3306
3551
|
etag: res.headers.get("etag") ?? void 0,
|
|
@@ -3323,8 +3568,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
|
|
|
3323
3568
|
if (keepUrls.has(url)) continue;
|
|
3324
3569
|
delete meta[url];
|
|
3325
3570
|
removed++;
|
|
3326
|
-
const safeFile = typeof entry.file === "string" && entry.file ===
|
|
3327
|
-
if (safeFile) await
|
|
3571
|
+
const safeFile = typeof entry.file === "string" && entry.file === path22.basename(entry.file);
|
|
3572
|
+
if (safeFile) await fs21.remove(path22.join(cacheDir, entry.file)).catch(() => {
|
|
3328
3573
|
});
|
|
3329
3574
|
}
|
|
3330
3575
|
if (removed > 0) await writeCacheMeta(cacheDir, meta);
|
|
@@ -3346,10 +3591,10 @@ async function fetchRefsForItems(items, cacheDir) {
|
|
|
3346
3591
|
const outcome = await fetchSingleRef(url, cacheDir, meta);
|
|
3347
3592
|
if (outcome.result === "fetched") {
|
|
3348
3593
|
summary.fetched++;
|
|
3349
|
-
summary.cachedFiles[url] =
|
|
3594
|
+
summary.cachedFiles[url] = path22.join(cacheDir, outcome.file);
|
|
3350
3595
|
} else if (outcome.result === "unchanged") {
|
|
3351
3596
|
summary.unchanged++;
|
|
3352
|
-
if (meta[url]?.file) summary.cachedFiles[url] =
|
|
3597
|
+
if (meta[url]?.file) summary.cachedFiles[url] = path22.join(cacheDir, meta[url].file);
|
|
3353
3598
|
} else {
|
|
3354
3599
|
summary.failed++;
|
|
3355
3600
|
summary.failedUrls.push(url);
|
|
@@ -3372,7 +3617,7 @@ function parseIdList(value) {
|
|
|
3372
3617
|
// src/commands/apply.ts
|
|
3373
3618
|
var STALE_CACHE_THRESHOLD_DAYS = 7;
|
|
3374
3619
|
async function cacheHasItems() {
|
|
3375
|
-
const data = await readJson(
|
|
3620
|
+
const data = await readJson(path23.join(getCacheDir(), "manifest.json"));
|
|
3376
3621
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
3377
3622
|
}
|
|
3378
3623
|
function installableRecommendedItems(recommended) {
|
|
@@ -3478,6 +3723,8 @@ async function runApply(options) {
|
|
|
3478
3723
|
force: options.force,
|
|
3479
3724
|
prune: options.prune
|
|
3480
3725
|
});
|
|
3726
|
+
await writeGitignore(root, isDryRun);
|
|
3727
|
+
await untrackMachineLocalArtifacts(root, isDryRun, warn);
|
|
3481
3728
|
if (!isDryRun) {
|
|
3482
3729
|
try {
|
|
3483
3730
|
const cacheDir = hausPath(root, "llms-cache");
|
|
@@ -3503,6 +3750,15 @@ async function runApply(options) {
|
|
|
3503
3750
|
);
|
|
3504
3751
|
}
|
|
3505
3752
|
}
|
|
3753
|
+
const gitignoredHausPaths = await findGitignoredHausPaths(root);
|
|
3754
|
+
if (gitignoredHausPaths.length > 0) {
|
|
3755
|
+
const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
|
|
3756
|
+
const dirList = dirs.join(" and ");
|
|
3757
|
+
const verb = dirs.length === 1 ? "is" : "are";
|
|
3758
|
+
warn(
|
|
3759
|
+
`${dirList} ${verb} gitignored \u2014 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).`
|
|
3760
|
+
);
|
|
3761
|
+
}
|
|
3506
3762
|
if (isDryRun) {
|
|
3507
3763
|
log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
3508
3764
|
} else {
|
|
@@ -3512,8 +3768,8 @@ async function runApply(options) {
|
|
|
3512
3768
|
}
|
|
3513
3769
|
|
|
3514
3770
|
// src/commands/backups.ts
|
|
3515
|
-
import
|
|
3516
|
-
import
|
|
3771
|
+
import path24 from "node:path";
|
|
3772
|
+
import fs22 from "fs-extra";
|
|
3517
3773
|
|
|
3518
3774
|
// src/utils/prompts.ts
|
|
3519
3775
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -3543,14 +3799,14 @@ function classify(name) {
|
|
|
3543
3799
|
}
|
|
3544
3800
|
async function listBackups(root) {
|
|
3545
3801
|
const dir = hausPath(root, "backups");
|
|
3546
|
-
if (!await
|
|
3547
|
-
const names = await
|
|
3802
|
+
if (!await fs22.pathExists(dir)) return [];
|
|
3803
|
+
const names = await fs22.readdir(dir);
|
|
3548
3804
|
const entries = [];
|
|
3549
3805
|
for (const name of names) {
|
|
3550
3806
|
const kind = classify(name);
|
|
3551
3807
|
if (!kind) continue;
|
|
3552
|
-
const absPath =
|
|
3553
|
-
const stat = await
|
|
3808
|
+
const absPath = path24.join(dir, name);
|
|
3809
|
+
const stat = await fs22.lstat(absPath);
|
|
3554
3810
|
if (stat.isSymbolicLink()) continue;
|
|
3555
3811
|
const expectDirectory = kind !== "lock";
|
|
3556
3812
|
if (stat.isDirectory() !== expectDirectory) continue;
|
|
@@ -3579,13 +3835,13 @@ async function runList(root) {
|
|
|
3579
3835
|
}
|
|
3580
3836
|
}
|
|
3581
3837
|
async function isUnsafeWriteTarget(root, target) {
|
|
3582
|
-
const segments =
|
|
3838
|
+
const segments = path24.relative(root, target).split(path24.sep).filter(Boolean);
|
|
3583
3839
|
let current = root;
|
|
3584
3840
|
for (let i = 0; i < segments.length; i++) {
|
|
3585
|
-
current =
|
|
3841
|
+
current = path24.join(current, segments[i]);
|
|
3586
3842
|
const isLastSegment = i === segments.length - 1;
|
|
3587
3843
|
try {
|
|
3588
|
-
const stat = await
|
|
3844
|
+
const stat = await fs22.lstat(current);
|
|
3589
3845
|
if (isLastSegment) {
|
|
3590
3846
|
if (!stat.isFile()) return true;
|
|
3591
3847
|
} else if (!stat.isDirectory()) {
|
|
@@ -3600,7 +3856,7 @@ async function isUnsafeWriteTarget(root, target) {
|
|
|
3600
3856
|
}
|
|
3601
3857
|
async function lstatIfRegularFile(abs) {
|
|
3602
3858
|
try {
|
|
3603
|
-
const stat = await
|
|
3859
|
+
const stat = await fs22.lstat(abs);
|
|
3604
3860
|
return stat.isFile() ? stat : null;
|
|
3605
3861
|
} catch (err) {
|
|
3606
3862
|
if (err.code === "ENOENT") return null;
|
|
@@ -3615,8 +3871,8 @@ async function restoreLockBackup(root, entry, yes) {
|
|
|
3615
3871
|
);
|
|
3616
3872
|
return false;
|
|
3617
3873
|
}
|
|
3618
|
-
if (await
|
|
3619
|
-
const targetStat = await
|
|
3874
|
+
if (await fs22.pathExists(target)) {
|
|
3875
|
+
const targetStat = await fs22.stat(target);
|
|
3620
3876
|
if (targetStat.mtimeMs > entry.mtimeMs) {
|
|
3621
3877
|
warn(
|
|
3622
3878
|
`Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
|
|
@@ -3636,7 +3892,7 @@ async function restoreLockBackup(root, entry, yes) {
|
|
|
3636
3892
|
error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
|
|
3637
3893
|
return false;
|
|
3638
3894
|
}
|
|
3639
|
-
await
|
|
3895
|
+
await fs22.copy(entry.absPath, target, { overwrite: true });
|
|
3640
3896
|
log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
|
|
3641
3897
|
return true;
|
|
3642
3898
|
}
|
|
@@ -3644,10 +3900,10 @@ async function collectFilesRecursive(dir) {
|
|
|
3644
3900
|
const files = [];
|
|
3645
3901
|
const skippedLinks = [];
|
|
3646
3902
|
const skippedOther = [];
|
|
3647
|
-
const names = await
|
|
3903
|
+
const names = await fs22.readdir(dir);
|
|
3648
3904
|
for (const name of names) {
|
|
3649
|
-
const abs =
|
|
3650
|
-
const stat = await
|
|
3905
|
+
const abs = path24.join(dir, name);
|
|
3906
|
+
const stat = await fs22.lstat(abs);
|
|
3651
3907
|
if (stat.isSymbolicLink()) {
|
|
3652
3908
|
skippedLinks.push(abs);
|
|
3653
3909
|
continue;
|
|
@@ -3669,12 +3925,12 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3669
3925
|
const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
|
|
3670
3926
|
if (skippedLinks.length > 0) {
|
|
3671
3927
|
warn(
|
|
3672
|
-
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) =>
|
|
3928
|
+
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
|
|
3673
3929
|
);
|
|
3674
3930
|
}
|
|
3675
3931
|
if (skippedOther.length > 0) {
|
|
3676
3932
|
warn(
|
|
3677
|
-
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) =>
|
|
3933
|
+
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path24.relative(entry.absPath, abs)).join(", ")}`
|
|
3678
3934
|
);
|
|
3679
3935
|
}
|
|
3680
3936
|
if (files.length === 0) {
|
|
@@ -3684,8 +3940,8 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3684
3940
|
const safeFiles = [];
|
|
3685
3941
|
const unsafeTargets = [];
|
|
3686
3942
|
for (const abs of files) {
|
|
3687
|
-
const rel =
|
|
3688
|
-
const target =
|
|
3943
|
+
const rel = path24.relative(entry.absPath, abs);
|
|
3944
|
+
const target = path24.join(root, rel);
|
|
3689
3945
|
if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
|
|
3690
3946
|
else safeFiles.push(abs);
|
|
3691
3947
|
}
|
|
@@ -3702,16 +3958,16 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3702
3958
|
const vanished = [];
|
|
3703
3959
|
const toRestore = [];
|
|
3704
3960
|
for (const abs of safeFiles) {
|
|
3705
|
-
const rel =
|
|
3961
|
+
const rel = path24.relative(entry.absPath, abs);
|
|
3706
3962
|
const backupFileStat = await lstatIfRegularFile(abs);
|
|
3707
3963
|
if (!backupFileStat) {
|
|
3708
3964
|
vanished.push(rel);
|
|
3709
3965
|
continue;
|
|
3710
3966
|
}
|
|
3711
3967
|
toRestore.push(abs);
|
|
3712
|
-
const target =
|
|
3713
|
-
if (await
|
|
3714
|
-
const targetStat = await
|
|
3968
|
+
const target = path24.join(root, rel);
|
|
3969
|
+
if (await fs22.pathExists(target)) {
|
|
3970
|
+
const targetStat = await fs22.stat(target);
|
|
3715
3971
|
if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
|
|
3716
3972
|
stale.push({
|
|
3717
3973
|
rel,
|
|
@@ -3750,14 +4006,14 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3750
4006
|
let restoredCount = 0;
|
|
3751
4007
|
const skippedAtWrite = [];
|
|
3752
4008
|
for (const abs of toRestore) {
|
|
3753
|
-
const rel =
|
|
3754
|
-
const target =
|
|
4009
|
+
const rel = path24.relative(entry.absPath, abs);
|
|
4010
|
+
const target = path24.join(root, rel);
|
|
3755
4011
|
if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
|
|
3756
4012
|
skippedAtWrite.push(rel);
|
|
3757
4013
|
continue;
|
|
3758
4014
|
}
|
|
3759
|
-
await
|
|
3760
|
-
await
|
|
4015
|
+
await fs22.ensureDir(path24.dirname(target));
|
|
4016
|
+
await fs22.copy(abs, target, { overwrite: true });
|
|
3761
4017
|
restoredCount++;
|
|
3762
4018
|
}
|
|
3763
4019
|
if (skippedAtWrite.length > 0) {
|
|
@@ -3814,7 +4070,7 @@ Keeping ${keepCount}.`
|
|
|
3814
4070
|
const failed = [];
|
|
3815
4071
|
for (const entry of removeList) {
|
|
3816
4072
|
try {
|
|
3817
|
-
await
|
|
4073
|
+
await fs22.remove(entry.absPath);
|
|
3818
4074
|
removed.push(entry.id);
|
|
3819
4075
|
} catch (err) {
|
|
3820
4076
|
failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
|
|
@@ -3895,19 +4151,133 @@ async function runCatalogAudit() {
|
|
|
3895
4151
|
}
|
|
3896
4152
|
|
|
3897
4153
|
// src/commands/doctor.ts
|
|
3898
|
-
import
|
|
3899
|
-
import
|
|
4154
|
+
import path38 from "node:path";
|
|
4155
|
+
import fs29 from "fs-extra";
|
|
3900
4156
|
|
|
3901
4157
|
// src/decisions/doctor.ts
|
|
3902
|
-
import
|
|
3903
|
-
import
|
|
4158
|
+
import path29 from "node:path";
|
|
4159
|
+
import fs26 from "fs-extra";
|
|
3904
4160
|
|
|
3905
4161
|
// src/decisions/check.ts
|
|
3906
|
-
import
|
|
3907
|
-
import
|
|
4162
|
+
import path28 from "node:path";
|
|
4163
|
+
import fs25 from "fs-extra";
|
|
4164
|
+
|
|
4165
|
+
// src/decisions/dep-bump.ts
|
|
4166
|
+
var DEP_SECTIONS = [
|
|
4167
|
+
"dependencies",
|
|
4168
|
+
"devDependencies",
|
|
4169
|
+
"peerDependencies",
|
|
4170
|
+
"optionalDependencies"
|
|
4171
|
+
];
|
|
4172
|
+
function isRecord(value) {
|
|
4173
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
4174
|
+
}
|
|
4175
|
+
function extractDepMap(pkg) {
|
|
4176
|
+
const map = /* @__PURE__ */ new Map();
|
|
4177
|
+
if (!isRecord(pkg)) return map;
|
|
4178
|
+
for (const section of DEP_SECTIONS) {
|
|
4179
|
+
const block = pkg[section];
|
|
4180
|
+
if (!isRecord(block)) continue;
|
|
4181
|
+
for (const [name, ver] of Object.entries(block)) {
|
|
4182
|
+
if (typeof ver === "string") map.set(`${section}:${name}`, ver);
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
return map;
|
|
4186
|
+
}
|
|
4187
|
+
function stripDepSections(pkg) {
|
|
4188
|
+
const out = { ...pkg };
|
|
4189
|
+
for (const section of DEP_SECTIONS) delete out[section];
|
|
4190
|
+
return out;
|
|
4191
|
+
}
|
|
4192
|
+
function mapsEqualKeys(a, b) {
|
|
4193
|
+
if (a.size !== b.size) return false;
|
|
4194
|
+
for (const k of a.keys()) if (!b.has(k)) return false;
|
|
4195
|
+
return true;
|
|
4196
|
+
}
|
|
4197
|
+
function isDependencyBumpOnly(beforeText, afterText) {
|
|
4198
|
+
let before;
|
|
4199
|
+
let after;
|
|
4200
|
+
try {
|
|
4201
|
+
before = JSON.parse(beforeText);
|
|
4202
|
+
after = JSON.parse(afterText);
|
|
4203
|
+
} catch {
|
|
4204
|
+
return false;
|
|
4205
|
+
}
|
|
4206
|
+
if (!isRecord(before) || !isRecord(after)) return false;
|
|
4207
|
+
const beforeDeps = extractDepMap(before);
|
|
4208
|
+
const afterDeps = extractDepMap(after);
|
|
4209
|
+
if (beforeDeps.size === 0 && afterDeps.size === 0) return false;
|
|
4210
|
+
if (!mapsEqualKeys(beforeDeps, afterDeps)) return false;
|
|
4211
|
+
if (JSON.stringify(stripDepSections(before)) !== JSON.stringify(stripDepSections(after))) {
|
|
4212
|
+
return false;
|
|
4213
|
+
}
|
|
4214
|
+
for (const [key, ver] of beforeDeps) {
|
|
4215
|
+
if (afterDeps.get(key) !== ver) return true;
|
|
4216
|
+
}
|
|
4217
|
+
return false;
|
|
4218
|
+
}
|
|
3908
4219
|
|
|
3909
4220
|
// src/decisions/diff.ts
|
|
3910
4221
|
init_exec();
|
|
4222
|
+
import path25 from "node:path";
|
|
4223
|
+
function isPackageJsonPath(file) {
|
|
4224
|
+
return file === "package.json" || file.endsWith("/package.json");
|
|
4225
|
+
}
|
|
4226
|
+
async function readBlob(root, spec) {
|
|
4227
|
+
const result = await runGit(["show", spec], { cwd: root });
|
|
4228
|
+
return result.exitCode === 0 ? result.stdout : null;
|
|
4229
|
+
}
|
|
4230
|
+
function parseDiffRange(range) {
|
|
4231
|
+
const tripleIdx = range.indexOf("...");
|
|
4232
|
+
if (tripleIdx !== -1) {
|
|
4233
|
+
return {
|
|
4234
|
+
left: range.slice(0, tripleIdx) || "HEAD",
|
|
4235
|
+
right: range.slice(tripleIdx + 3) || "HEAD",
|
|
4236
|
+
triple: true
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
const idx = range.indexOf("..");
|
|
4240
|
+
if (idx !== -1) {
|
|
4241
|
+
return {
|
|
4242
|
+
left: range.slice(0, idx) || "HEAD",
|
|
4243
|
+
right: range.slice(idx + 2) || "HEAD",
|
|
4244
|
+
triple: false
|
|
4245
|
+
};
|
|
4246
|
+
}
|
|
4247
|
+
return null;
|
|
4248
|
+
}
|
|
4249
|
+
async function getCurrentBranch(root) {
|
|
4250
|
+
const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
|
|
4251
|
+
if (result.exitCode !== 0) return void 0;
|
|
4252
|
+
const name = result.stdout.trim();
|
|
4253
|
+
if (name && name !== "HEAD") return name;
|
|
4254
|
+
const gha = process.env.GITHUB_HEAD_REF?.trim();
|
|
4255
|
+
if (gha) return gha;
|
|
4256
|
+
return void 0;
|
|
4257
|
+
}
|
|
4258
|
+
async function resolvePackageJsonPair(root, file, opts) {
|
|
4259
|
+
const empty = "{}";
|
|
4260
|
+
if (opts.staged) {
|
|
4261
|
+
const before2 = await readBlob(root, `HEAD:${file}`) ?? empty;
|
|
4262
|
+
const after2 = await readBlob(root, `:${file}`) ?? empty;
|
|
4263
|
+
return { before: before2, after: after2 };
|
|
4264
|
+
}
|
|
4265
|
+
const range = opts.range ?? "HEAD";
|
|
4266
|
+
const parsed = parseDiffRange(range);
|
|
4267
|
+
if (parsed) {
|
|
4268
|
+
let leftRev = parsed.left;
|
|
4269
|
+
if (parsed.triple) {
|
|
4270
|
+
const mb = await runGit(["merge-base", parsed.left, parsed.right], { cwd: root });
|
|
4271
|
+
leftRev = mb.exitCode === 0 ? mb.stdout.trim() : parsed.left;
|
|
4272
|
+
}
|
|
4273
|
+
const before2 = await readBlob(root, `${leftRev}:${file}`) ?? empty;
|
|
4274
|
+
const after2 = await readBlob(root, `${parsed.right}:${file}`) ?? empty;
|
|
4275
|
+
return { before: before2, after: after2 };
|
|
4276
|
+
}
|
|
4277
|
+
const before = await readBlob(root, `${range}:${file}`) ?? empty;
|
|
4278
|
+
const after = await readText(path25.join(root, file)) ?? empty;
|
|
4279
|
+
return { before, after };
|
|
4280
|
+
}
|
|
3911
4281
|
async function collectDiffStats(root, opts) {
|
|
3912
4282
|
const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
|
|
3913
4283
|
const names = await runGit(gitArgs, { cwd: root });
|
|
@@ -3924,9 +4294,23 @@ async function collectDiffStats(root, opts) {
|
|
|
3924
4294
|
if (!Number.isNaN(added)) linesAdded += added;
|
|
3925
4295
|
if (!Number.isNaN(removed)) linesRemoved += removed;
|
|
3926
4296
|
}
|
|
3927
|
-
|
|
4297
|
+
const packageJsonPairs = {};
|
|
4298
|
+
for (const file of files) {
|
|
4299
|
+
if (!isPackageJsonPath(file)) continue;
|
|
4300
|
+
packageJsonPairs[file] = await resolvePackageJsonPair(root, file, opts);
|
|
4301
|
+
}
|
|
4302
|
+
return {
|
|
4303
|
+
files,
|
|
4304
|
+
linesAdded,
|
|
4305
|
+
linesRemoved,
|
|
4306
|
+
...Object.keys(packageJsonPairs).length > 0 ? { packageJsonPairs } : {}
|
|
4307
|
+
};
|
|
3928
4308
|
}
|
|
3929
4309
|
|
|
4310
|
+
// src/decisions/local-exemptions.ts
|
|
4311
|
+
var LOCAL_EXEMPT_GLOBS = ["library/catalog/**"];
|
|
4312
|
+
var LOCAL_EXEMPT_BRANCH_PATTERN = /^chore\/sync-catalog/;
|
|
4313
|
+
|
|
3930
4314
|
// src/decisions/match.ts
|
|
3931
4315
|
function segmentToRegex(segment) {
|
|
3932
4316
|
if (segment === "**") return ".*";
|
|
@@ -3954,27 +4338,30 @@ function matchesAnyGlob(filePath, patterns) {
|
|
|
3954
4338
|
return patterns.some((p) => matchesPathGlob(filePath, p));
|
|
3955
4339
|
}
|
|
3956
4340
|
function matchesAnyRegex(filePath, patterns) {
|
|
4341
|
+
return firstMatchingRegex(filePath, patterns) != null;
|
|
4342
|
+
}
|
|
4343
|
+
function firstMatchingRegex(filePath, patterns) {
|
|
3957
4344
|
const norm = filePath.replace(/\\/g, "/");
|
|
3958
|
-
return patterns.
|
|
4345
|
+
return patterns.find((p) => new RegExp(p).test(norm));
|
|
3959
4346
|
}
|
|
3960
4347
|
|
|
3961
4348
|
// src/decisions/paths.ts
|
|
3962
|
-
import
|
|
3963
|
-
import
|
|
4349
|
+
import path26 from "node:path";
|
|
4350
|
+
import fs23 from "fs-extra";
|
|
3964
4351
|
async function resolveDecisionsDir(root) {
|
|
3965
|
-
const primary =
|
|
3966
|
-
if (await
|
|
3967
|
-
const legacy =
|
|
3968
|
-
if (await
|
|
4352
|
+
const primary = path26.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4353
|
+
if (await fs23.pathExists(primary)) return primary;
|
|
4354
|
+
const legacy = path26.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4355
|
+
if (await fs23.pathExists(legacy)) return legacy;
|
|
3969
4356
|
return primary;
|
|
3970
4357
|
}
|
|
3971
4358
|
function relativeDecisionPath(decisionsDir, root) {
|
|
3972
|
-
return
|
|
4359
|
+
return path26.relative(root, decisionsDir).replace(/\\/g, "/");
|
|
3973
4360
|
}
|
|
3974
4361
|
|
|
3975
4362
|
// src/decisions/validate.ts
|
|
3976
|
-
import
|
|
3977
|
-
import
|
|
4363
|
+
import path27 from "node:path";
|
|
4364
|
+
import fs24 from "fs-extra";
|
|
3978
4365
|
function validateDecisionContent(content2) {
|
|
3979
4366
|
const errors = [];
|
|
3980
4367
|
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
@@ -4003,14 +4390,14 @@ function decisionNumberFromFile(name) {
|
|
|
4003
4390
|
return match?.[1] ?? null;
|
|
4004
4391
|
}
|
|
4005
4392
|
async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
4006
|
-
const readmePath =
|
|
4007
|
-
if (!await
|
|
4393
|
+
const readmePath = path27.join(decisionsDir, "README.md");
|
|
4394
|
+
if (!await fs24.pathExists(readmePath)) {
|
|
4008
4395
|
return ["docs/decisions/README.md is missing"];
|
|
4009
4396
|
}
|
|
4010
4397
|
const readme = await readText(readmePath) ?? "";
|
|
4011
4398
|
const errors = [];
|
|
4012
4399
|
for (const file of decisionFiles) {
|
|
4013
|
-
const num = decisionNumberFromFile(
|
|
4400
|
+
const num = decisionNumberFromFile(path27.basename(file));
|
|
4014
4401
|
if (num && !indexReferencesNumber(readme, num)) {
|
|
4015
4402
|
errors.push(`README.md missing index row for ADR-${num}`);
|
|
4016
4403
|
}
|
|
@@ -4019,8 +4406,35 @@ async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
|
4019
4406
|
}
|
|
4020
4407
|
|
|
4021
4408
|
// src/decisions/check.ts
|
|
4409
|
+
var MANIFEST_LOCK_BASENAMES = /* @__PURE__ */ new Set([
|
|
4410
|
+
"package.json",
|
|
4411
|
+
"yarn.lock",
|
|
4412
|
+
"pnpm-lock.yaml",
|
|
4413
|
+
"package-lock.json"
|
|
4414
|
+
]);
|
|
4022
4415
|
function isExempt(file) {
|
|
4023
|
-
return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
|
|
4416
|
+
return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs) || matchesAnyGlob(file, [...LOCAL_EXEMPT_GLOBS]);
|
|
4417
|
+
}
|
|
4418
|
+
function isManifestOrLockfile(file) {
|
|
4419
|
+
const base = file.split("/").pop() ?? file;
|
|
4420
|
+
return MANIFEST_LOCK_BASENAMES.has(base);
|
|
4421
|
+
}
|
|
4422
|
+
function isPackageJsonPath2(file) {
|
|
4423
|
+
return file === "package.json" || file.endsWith("/package.json");
|
|
4424
|
+
}
|
|
4425
|
+
function isPureDependencyBumpDiff(stats) {
|
|
4426
|
+
const relevant = stats.files.filter((f) => !isExempt(f));
|
|
4427
|
+
if (relevant.length === 0) return false;
|
|
4428
|
+
if (relevant.some((f) => !isManifestOrLockfile(f))) return false;
|
|
4429
|
+
const pkgJsons = relevant.filter(isPackageJsonPath2);
|
|
4430
|
+
if (pkgJsons.length === 0) {
|
|
4431
|
+
return true;
|
|
4432
|
+
}
|
|
4433
|
+
const pairs = stats.packageJsonPairs ?? {};
|
|
4434
|
+
return pkgJsons.every((f) => {
|
|
4435
|
+
const pair = pairs[f];
|
|
4436
|
+
return pair != null && isDependencyBumpOnly(pair.before, pair.after);
|
|
4437
|
+
});
|
|
4024
4438
|
}
|
|
4025
4439
|
function countTopLevelDirs(files) {
|
|
4026
4440
|
const dirs = /* @__PURE__ */ new Set();
|
|
@@ -4030,21 +4444,35 @@ function countTopLevelDirs(files) {
|
|
|
4030
4444
|
}
|
|
4031
4445
|
return dirs.size;
|
|
4032
4446
|
}
|
|
4033
|
-
function
|
|
4034
|
-
|
|
4035
|
-
|
|
4447
|
+
function firstMatchingGlob(file, globs) {
|
|
4448
|
+
return globs.find((g) => matchesPathGlob(file, g));
|
|
4449
|
+
}
|
|
4450
|
+
function explainDecisionTrigger(stats) {
|
|
4451
|
+
let relevant = stats.files.filter((f) => !isExempt(f));
|
|
4452
|
+
if (relevant.length === 0) return [];
|
|
4453
|
+
if (isPureDependencyBumpDiff(stats)) {
|
|
4454
|
+
relevant = relevant.filter((f) => !isManifestOrLockfile(f));
|
|
4455
|
+
if (relevant.length === 0) return [];
|
|
4456
|
+
}
|
|
4036
4457
|
for (const file of relevant) {
|
|
4037
|
-
|
|
4038
|
-
if (
|
|
4458
|
+
const glob = firstMatchingGlob(file, DECISIONS_TRIGGERS.pathGlobs);
|
|
4459
|
+
if (glob) {
|
|
4460
|
+
return [`matched pathGlobs: ${glob}`];
|
|
4461
|
+
}
|
|
4462
|
+
const regex = firstMatchingRegex(file, DECISIONS_TRIGGERS.pathRegex);
|
|
4463
|
+
if (regex) {
|
|
4464
|
+
return [`matched pathRegex: ${regex}`];
|
|
4465
|
+
}
|
|
4039
4466
|
}
|
|
4040
4467
|
const totalLines = stats.linesAdded + stats.linesRemoved;
|
|
4041
|
-
|
|
4042
|
-
|
|
4468
|
+
const dirs = countTopLevelDirs(relevant);
|
|
4469
|
+
if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && dirs >= 2) {
|
|
4470
|
+
return [`size threshold: ${relevant.length} files across ${dirs} top-level dirs`];
|
|
4043
4471
|
}
|
|
4044
4472
|
if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
|
|
4045
|
-
return
|
|
4473
|
+
return [`size threshold: ${totalLines} lines changed (non-docs)`];
|
|
4046
4474
|
}
|
|
4047
|
-
return
|
|
4475
|
+
return [];
|
|
4048
4476
|
}
|
|
4049
4477
|
function isSecurityTriggered(stats) {
|
|
4050
4478
|
const relevant = stats.files.filter((f) => !isExempt(f));
|
|
@@ -4064,26 +4492,27 @@ async function checkSatisfaction(input2) {
|
|
|
4064
4492
|
const errors = [];
|
|
4065
4493
|
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
4066
4494
|
const newDecisionFiles = input2.changedFiles.filter(
|
|
4067
|
-
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(
|
|
4495
|
+
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path28.basename(f))
|
|
4068
4496
|
);
|
|
4069
4497
|
if (newDecisionFiles.length === 0) {
|
|
4070
4498
|
errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
|
|
4071
4499
|
return errors;
|
|
4072
4500
|
}
|
|
4073
|
-
const decisionsDir =
|
|
4501
|
+
const decisionsDir = path28.join(input2.root, input2.decisionsRel);
|
|
4074
4502
|
for (const rel of newDecisionFiles) {
|
|
4075
|
-
const abs =
|
|
4503
|
+
const abs = path28.join(input2.root, rel);
|
|
4076
4504
|
const content2 = await readText(abs) ?? "";
|
|
4077
4505
|
const valid = validateDecisionContent(content2);
|
|
4078
4506
|
if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
4079
4507
|
}
|
|
4080
4508
|
const indexErrors = await validateIndexSync(
|
|
4081
4509
|
decisionsDir,
|
|
4082
|
-
newDecisionFiles.map((f) =>
|
|
4510
|
+
newDecisionFiles.map((f) => path28.basename(f))
|
|
4083
4511
|
);
|
|
4084
4512
|
errors.push(...indexErrors);
|
|
4085
4513
|
return errors;
|
|
4086
4514
|
}
|
|
4515
|
+
var WAYS_OUT = "Write an ADR under docs/decisions/ (haus decisions suggest), or use [adr-skip] for non-security changes.";
|
|
4087
4516
|
async function runDecisionsCheck(root, opts = {}) {
|
|
4088
4517
|
const reasons = [];
|
|
4089
4518
|
if (await readGateMode(root) === "off") {
|
|
@@ -4095,8 +4524,18 @@ async function runDecisionsCheck(root, opts = {}) {
|
|
|
4095
4524
|
};
|
|
4096
4525
|
}
|
|
4097
4526
|
const stats = await collectDiffStats(root, opts);
|
|
4098
|
-
const triggered = isDecisionTriggered(stats);
|
|
4099
4527
|
const securityTriggered = isSecurityTriggered(stats);
|
|
4528
|
+
const branch = await getCurrentBranch(root);
|
|
4529
|
+
if (branch != null && LOCAL_EXEMPT_BRANCH_PATTERN.test(branch) && !securityTriggered) {
|
|
4530
|
+
return {
|
|
4531
|
+
triggered: false,
|
|
4532
|
+
satisfied: true,
|
|
4533
|
+
reasons: ["catalog-sync branch \u2014 exempt"],
|
|
4534
|
+
securityTriggered: false
|
|
4535
|
+
};
|
|
4536
|
+
}
|
|
4537
|
+
const triggerReasons = explainDecisionTrigger(stats);
|
|
4538
|
+
const triggered = triggerReasons.length > 0;
|
|
4100
4539
|
if (!triggered) {
|
|
4101
4540
|
return {
|
|
4102
4541
|
triggered: false,
|
|
@@ -4105,6 +4544,7 @@ async function runDecisionsCheck(root, opts = {}) {
|
|
|
4105
4544
|
securityTriggered
|
|
4106
4545
|
};
|
|
4107
4546
|
}
|
|
4547
|
+
reasons.push(...triggerReasons);
|
|
4108
4548
|
const skipText = `${opts.prBody ?? ""}
|
|
4109
4549
|
${opts.commitMessages ?? ""}`;
|
|
4110
4550
|
if (hasSkipToken(skipText) && !securityTriggered) {
|
|
@@ -4123,8 +4563,8 @@ ${opts.commitMessages ?? ""}`;
|
|
|
4123
4563
|
changedFiles: stats.files
|
|
4124
4564
|
});
|
|
4125
4565
|
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
4126
|
-
const legacyExists = await
|
|
4127
|
-
if (legacyExists && !await
|
|
4566
|
+
const legacyExists = await fs25.pathExists(path28.join(root, legacyRel));
|
|
4567
|
+
if (legacyExists && !await fs25.pathExists(decisionsDir)) {
|
|
4128
4568
|
satisfactionErrors = await checkSatisfaction({
|
|
4129
4569
|
root,
|
|
4130
4570
|
decisionsRel: legacyRel,
|
|
@@ -4133,29 +4573,31 @@ ${opts.commitMessages ?? ""}`;
|
|
|
4133
4573
|
}
|
|
4134
4574
|
}
|
|
4135
4575
|
if (satisfactionErrors.length === 0) {
|
|
4576
|
+
reasons.push("decision record present");
|
|
4136
4577
|
return {
|
|
4137
4578
|
triggered: true,
|
|
4138
4579
|
satisfied: true,
|
|
4139
|
-
reasons
|
|
4580
|
+
reasons,
|
|
4140
4581
|
securityTriggered
|
|
4141
4582
|
};
|
|
4142
4583
|
}
|
|
4143
4584
|
reasons.push(...satisfactionErrors);
|
|
4585
|
+
reasons.push(WAYS_OUT);
|
|
4144
4586
|
return { triggered: true, satisfied: false, reasons, securityTriggered };
|
|
4145
4587
|
}
|
|
4146
4588
|
async function listDecisionNumbers(decisionsDir) {
|
|
4147
|
-
if (!await
|
|
4148
|
-
const entries = await
|
|
4589
|
+
if (!await fs25.pathExists(decisionsDir)) return [];
|
|
4590
|
+
const entries = await fs25.readdir(decisionsDir);
|
|
4149
4591
|
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
4150
4592
|
}
|
|
4151
4593
|
|
|
4152
4594
|
// src/decisions/doctor.ts
|
|
4153
4595
|
async function auditDecisionsLayout(root) {
|
|
4154
4596
|
const findings = [];
|
|
4155
|
-
const primary =
|
|
4156
|
-
const legacy =
|
|
4157
|
-
const hasPrimary = await
|
|
4158
|
-
const hasLegacy = await
|
|
4597
|
+
const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4598
|
+
const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4599
|
+
const hasPrimary = await fs26.pathExists(primary);
|
|
4600
|
+
const hasLegacy = await fs26.pathExists(legacy);
|
|
4159
4601
|
if (!hasPrimary && hasLegacy) {
|
|
4160
4602
|
findings.push({
|
|
4161
4603
|
level: "advisory",
|
|
@@ -4168,8 +4610,8 @@ async function auditDecisionsLayout(root) {
|
|
|
4168
4610
|
if (!hasPrimary) return findings;
|
|
4169
4611
|
const decisionsDir = await resolveDecisionsDir(root);
|
|
4170
4612
|
const rel = relativeDecisionPath(decisionsDir, root);
|
|
4171
|
-
const readmePath =
|
|
4172
|
-
if (!await
|
|
4613
|
+
const readmePath = path29.join(decisionsDir, "README.md");
|
|
4614
|
+
if (!await fs26.pathExists(readmePath)) {
|
|
4173
4615
|
findings.push({
|
|
4174
4616
|
level: "advisory",
|
|
4175
4617
|
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
@@ -4179,7 +4621,7 @@ async function auditDecisionsLayout(root) {
|
|
|
4179
4621
|
return findings;
|
|
4180
4622
|
}
|
|
4181
4623
|
const readme = await readText(readmePath);
|
|
4182
|
-
const entries = (await
|
|
4624
|
+
const entries = (await fs26.readdir(decisionsDir)).filter(
|
|
4183
4625
|
(f) => f.endsWith(".md") && f !== "README.md"
|
|
4184
4626
|
);
|
|
4185
4627
|
for (const file of entries) {
|
|
@@ -4193,7 +4635,7 @@ async function auditDecisionsLayout(root) {
|
|
|
4193
4635
|
});
|
|
4194
4636
|
}
|
|
4195
4637
|
}
|
|
4196
|
-
const claude = await readText(
|
|
4638
|
+
const claude = await readText(path29.join(root, "CLAUDE.md"));
|
|
4197
4639
|
if (claude && !claude.includes(`@${rel}/README.md`)) {
|
|
4198
4640
|
findings.push({
|
|
4199
4641
|
level: "advisory",
|
|
@@ -4224,15 +4666,185 @@ async function auditDecisionsLayout(root) {
|
|
|
4224
4666
|
}
|
|
4225
4667
|
|
|
4226
4668
|
// src/scanner/read-context.ts
|
|
4227
|
-
import
|
|
4228
|
-
import
|
|
4229
|
-
|
|
4230
|
-
// src/scanner/scan-project.ts
|
|
4231
|
-
import path31 from "node:path";
|
|
4669
|
+
import path37 from "node:path";
|
|
4670
|
+
import fs28 from "fs-extra";
|
|
4232
4671
|
|
|
4233
|
-
// src/
|
|
4234
|
-
|
|
4235
|
-
|
|
4672
|
+
// src/commands/workspace/config.ts
|
|
4673
|
+
import path30 from "node:path";
|
|
4674
|
+
import YAML from "yaml";
|
|
4675
|
+
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
4676
|
+
function parseWorkspaceConfig(text) {
|
|
4677
|
+
if (!text) return void 0;
|
|
4678
|
+
let parsed;
|
|
4679
|
+
try {
|
|
4680
|
+
parsed = YAML.parse(text);
|
|
4681
|
+
} catch {
|
|
4682
|
+
return void 0;
|
|
4683
|
+
}
|
|
4684
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
4685
|
+
const obj = parsed;
|
|
4686
|
+
const repos = Array.isArray(obj.repos) ? obj.repos.filter(
|
|
4687
|
+
(r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
|
|
4688
|
+
) : [];
|
|
4689
|
+
return {
|
|
4690
|
+
client: typeof obj.client === "string" ? obj.client : "unknown",
|
|
4691
|
+
repos,
|
|
4692
|
+
relationships: Array.isArray(obj.relationships) ? obj.relationships : []
|
|
4693
|
+
};
|
|
4694
|
+
}
|
|
4695
|
+
async function readWorkspaceConfig(workspaceRoot) {
|
|
4696
|
+
return parseWorkspaceConfig(await readText(path30.join(workspaceRoot, WORKSPACE_FILE)));
|
|
4697
|
+
}
|
|
4698
|
+
|
|
4699
|
+
// src/workspace/members.ts
|
|
4700
|
+
import path31 from "node:path";
|
|
4701
|
+
var REPOS_MANIFEST_FILE = "repos.manifest.json";
|
|
4702
|
+
var REPOS_LOCAL_FILE = "repos.local.json";
|
|
4703
|
+
var MemberConfigError = class extends Error {
|
|
4704
|
+
constructor(filePath, reason) {
|
|
4705
|
+
super(`Cannot read workspace member config at ${filePath}: ${reason}`);
|
|
4706
|
+
this.filePath = filePath;
|
|
4707
|
+
}
|
|
4708
|
+
filePath;
|
|
4709
|
+
name = "MemberConfigError";
|
|
4710
|
+
};
|
|
4711
|
+
function resolveAbsPath(workspaceRoot, folder, overrides) {
|
|
4712
|
+
const override = overrides[folder];
|
|
4713
|
+
if (override) return path31.resolve(workspaceRoot, override);
|
|
4714
|
+
return path31.resolve(workspaceRoot, folder);
|
|
4715
|
+
}
|
|
4716
|
+
async function readPathOverrides(workspaceRoot) {
|
|
4717
|
+
const filePath = path31.join(workspaceRoot, REPOS_LOCAL_FILE);
|
|
4718
|
+
const result = await readJsonDetailed(filePath);
|
|
4719
|
+
if (result.status === "missing") return {};
|
|
4720
|
+
if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
|
|
4721
|
+
const parsed = result.value;
|
|
4722
|
+
const rawOverrides = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.pathOverrides : void 0;
|
|
4723
|
+
if (rawOverrides === null || typeof rawOverrides !== "object" || Array.isArray(rawOverrides)) {
|
|
4724
|
+
return {};
|
|
4725
|
+
}
|
|
4726
|
+
const overrides = {};
|
|
4727
|
+
for (const [folder, value] of Object.entries(rawOverrides)) {
|
|
4728
|
+
if (typeof value === "string") overrides[folder] = value;
|
|
4729
|
+
}
|
|
4730
|
+
return overrides;
|
|
4731
|
+
}
|
|
4732
|
+
async function readFromWorkspaceYaml(workspaceRoot, overrides) {
|
|
4733
|
+
const filePath = path31.join(workspaceRoot, WORKSPACE_FILE);
|
|
4734
|
+
const text = await readText(filePath);
|
|
4735
|
+
if (text === void 0) return void 0;
|
|
4736
|
+
const config = parseWorkspaceConfig(text);
|
|
4737
|
+
if (!config) throw new MemberConfigError(filePath, "malformed YAML or invalid shape");
|
|
4738
|
+
return config.repos.map((repo) => ({
|
|
4739
|
+
id: repo.name,
|
|
4740
|
+
folder: repo.path,
|
|
4741
|
+
absPath: resolveAbsPath(workspaceRoot, repo.path, overrides),
|
|
4742
|
+
source: "haus.workspace.yaml"
|
|
4743
|
+
}));
|
|
4744
|
+
}
|
|
4745
|
+
async function readFromManifest(workspaceRoot, overrides) {
|
|
4746
|
+
const filePath = path31.join(workspaceRoot, REPOS_MANIFEST_FILE);
|
|
4747
|
+
const result = await readJsonDetailed(filePath);
|
|
4748
|
+
if (result.status === "missing") return void 0;
|
|
4749
|
+
if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
|
|
4750
|
+
const parsed = result.value;
|
|
4751
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4752
|
+
throw new MemberConfigError(filePath, 'expected a top-level object with a "repos" array');
|
|
4753
|
+
}
|
|
4754
|
+
const raw = parsed.repos;
|
|
4755
|
+
if (!Array.isArray(raw)) {
|
|
4756
|
+
throw new MemberConfigError(filePath, '"repos" must be an array');
|
|
4757
|
+
}
|
|
4758
|
+
const members = [];
|
|
4759
|
+
raw.forEach((entry, index) => {
|
|
4760
|
+
if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || typeof entry.folder !== "string") {
|
|
4761
|
+
throw new MemberConfigError(
|
|
4762
|
+
filePath,
|
|
4763
|
+
`repos[${index}] must be an object with string "id" and "folder" fields`
|
|
4764
|
+
);
|
|
4765
|
+
}
|
|
4766
|
+
const e = entry;
|
|
4767
|
+
members.push({
|
|
4768
|
+
id: e.id,
|
|
4769
|
+
folder: e.folder,
|
|
4770
|
+
url: typeof e.repo === "string" ? e.repo : void 0,
|
|
4771
|
+
absPath: resolveAbsPath(workspaceRoot, e.folder, overrides),
|
|
4772
|
+
source: "repos.manifest.json"
|
|
4773
|
+
});
|
|
4774
|
+
});
|
|
4775
|
+
return members;
|
|
4776
|
+
}
|
|
4777
|
+
async function readMembers(rootInfo) {
|
|
4778
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
4779
|
+
const overrides = await readPathOverrides(workspaceRoot);
|
|
4780
|
+
const fromYaml = await readFromWorkspaceYaml(workspaceRoot, overrides);
|
|
4781
|
+
if (fromYaml) return fromYaml;
|
|
4782
|
+
const fromManifest = await readFromManifest(workspaceRoot, overrides);
|
|
4783
|
+
if (fromManifest) return fromManifest;
|
|
4784
|
+
return [];
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
// src/scanner/scan-project.ts
|
|
4788
|
+
import path36 from "node:path";
|
|
4789
|
+
|
|
4790
|
+
// src/utils/audit-checks.ts
|
|
4791
|
+
function isRecord2(v) {
|
|
4792
|
+
return typeof v === "object" && v !== null;
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
// src/utils/git-root.ts
|
|
4796
|
+
init_exec();
|
|
4797
|
+
import path32 from "node:path";
|
|
4798
|
+
function fallback(start) {
|
|
4799
|
+
return {
|
|
4800
|
+
cwd: start,
|
|
4801
|
+
repoRoot: start,
|
|
4802
|
+
gitDir: "",
|
|
4803
|
+
gitCommonDir: "",
|
|
4804
|
+
isLinkedWorktree: false,
|
|
4805
|
+
mainRoot: start,
|
|
4806
|
+
worktreeName: null,
|
|
4807
|
+
isGitRepo: false
|
|
4808
|
+
};
|
|
4809
|
+
}
|
|
4810
|
+
async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
|
|
4811
|
+
const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
|
|
4812
|
+
const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
|
|
4813
|
+
if (raw && path32.isAbsolute(raw)) return path32.resolve(raw);
|
|
4814
|
+
const plain = await runGit(["rev-parse", flag], { cwd });
|
|
4815
|
+
const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
|
|
4816
|
+
if (!plainRaw) return "";
|
|
4817
|
+
return path32.isAbsolute(plainRaw) ? path32.resolve(plainRaw) : path32.resolve(repoRoot, plainRaw);
|
|
4818
|
+
}
|
|
4819
|
+
async function resolveRoots(start) {
|
|
4820
|
+
const cwd = start ?? process.cwd();
|
|
4821
|
+
try {
|
|
4822
|
+
const toplevel = await runGit(["rev-parse", "--show-toplevel"], { cwd });
|
|
4823
|
+
if (toplevel.exitCode !== 0) return fallback(cwd);
|
|
4824
|
+
const toplevelRaw = toplevel.stdout.trim();
|
|
4825
|
+
if (!toplevelRaw) return fallback(cwd);
|
|
4826
|
+
const repoRoot = path32.resolve(toplevelRaw);
|
|
4827
|
+
const [gitDir, gitCommonDir] = await Promise.all([
|
|
4828
|
+
resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
|
|
4829
|
+
resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
|
|
4830
|
+
]);
|
|
4831
|
+
if (!gitDir || !gitCommonDir) return fallback(cwd);
|
|
4832
|
+
const isLinkedWorktree = gitDir !== gitCommonDir;
|
|
4833
|
+
const mainRoot = isLinkedWorktree && path32.basename(gitCommonDir) === ".git" ? path32.dirname(gitCommonDir) : repoRoot;
|
|
4834
|
+
const worktreeName = isLinkedWorktree ? path32.basename(gitDir) : null;
|
|
4835
|
+
return {
|
|
4836
|
+
cwd,
|
|
4837
|
+
repoRoot,
|
|
4838
|
+
gitDir,
|
|
4839
|
+
gitCommonDir,
|
|
4840
|
+
isLinkedWorktree,
|
|
4841
|
+
mainRoot,
|
|
4842
|
+
worktreeName,
|
|
4843
|
+
isGitRepo: true
|
|
4844
|
+
};
|
|
4845
|
+
} catch {
|
|
4846
|
+
return fallback(cwd);
|
|
4847
|
+
}
|
|
4236
4848
|
}
|
|
4237
4849
|
|
|
4238
4850
|
// src/utils/versions.ts
|
|
@@ -4255,8 +4867,8 @@ function compareVersions(a, b) {
|
|
|
4255
4867
|
}
|
|
4256
4868
|
|
|
4257
4869
|
// src/scanner/detect-package-manager.ts
|
|
4258
|
-
import
|
|
4259
|
-
import
|
|
4870
|
+
import path33 from "node:path";
|
|
4871
|
+
import fs27 from "fs-extra";
|
|
4260
4872
|
function detectPackageManager(root, packageManagerField) {
|
|
4261
4873
|
const field = String(packageManagerField ?? "").trim();
|
|
4262
4874
|
if (field.startsWith("yarn@")) {
|
|
@@ -4274,9 +4886,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
4274
4886
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
4275
4887
|
return "unknown";
|
|
4276
4888
|
}
|
|
4277
|
-
if (
|
|
4278
|
-
if (
|
|
4279
|
-
if (
|
|
4889
|
+
if (fs27.existsSync(path33.join(root, "yarn.lock"))) return "yarn";
|
|
4890
|
+
if (fs27.existsSync(path33.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
4891
|
+
if (fs27.existsSync(path33.join(root, "package-lock.json"))) return "npm";
|
|
4280
4892
|
return "unknown";
|
|
4281
4893
|
}
|
|
4282
4894
|
|
|
@@ -4346,6 +4958,14 @@ function matchRule(rule, ctx) {
|
|
|
4346
4958
|
return false;
|
|
4347
4959
|
}
|
|
4348
4960
|
var ROLE_RULES = [
|
|
4961
|
+
// Workspace/meta-repo root — same bridge signal `readMembers()`/
|
|
4962
|
+
// `resolveWorkspaceForWorktree()` already use (src/workspace/members.ts,
|
|
4963
|
+
// src/workspace/worktree/root.ts): presence of either config file marks this
|
|
4964
|
+
// root as a workspace aggregating sibling repos, not a runnable app of its own.
|
|
4965
|
+
// `fileEquals` only matches the file at the scan root (not nested under a
|
|
4966
|
+
// sub-repo, which the nested-`.git` boundary excludes from `files` anyway) —
|
|
4967
|
+
// see Task 3.5, docs/plans/workspace-detection-and-permissions-fixes.md.
|
|
4968
|
+
{ role: "workspace", any: [fileEquals(WORKSPACE_FILE), fileEquals(REPOS_MANIFEST_FILE)] },
|
|
4349
4969
|
{ role: "next-app", any: [dep("next"), fileIncludes("next.config.")] },
|
|
4350
4970
|
{ role: "react-app", any: [dep("react")] },
|
|
4351
4971
|
{ role: "vite-app", any: [dep("vite"), fileIncludes("vite.config.")] },
|
|
@@ -4478,7 +5098,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
4478
5098
|
}
|
|
4479
5099
|
|
|
4480
5100
|
// src/scanner/detection.ts
|
|
4481
|
-
import
|
|
5101
|
+
import path34 from "node:path";
|
|
4482
5102
|
var UNSUPPORTED_MARKERS = {
|
|
4483
5103
|
"requirements.txt": "python",
|
|
4484
5104
|
"pyproject.toml": "python",
|
|
@@ -4498,13 +5118,19 @@ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
|
|
|
4498
5118
|
if (!hasRealSignal) return "unknown";
|
|
4499
5119
|
return unsupportedSignals.length > 0 ? "partial" : "supported";
|
|
4500
5120
|
}
|
|
5121
|
+
function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
|
|
5122
|
+
const markers = unsupportedSignals.join(", ");
|
|
5123
|
+
const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
|
|
5124
|
+
const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
|
|
5125
|
+
return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
|
|
5126
|
+
}
|
|
4501
5127
|
function blocked(rel) {
|
|
4502
5128
|
return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
|
|
4503
5129
|
}
|
|
4504
5130
|
function dependencySet(pkg, composer) {
|
|
4505
5131
|
const depNames = /* @__PURE__ */ new Set();
|
|
4506
5132
|
const pushObj = (obj) => {
|
|
4507
|
-
if (!
|
|
5133
|
+
if (!isRecord2(obj)) return;
|
|
4508
5134
|
for (const key of Object.keys(obj)) depNames.add(key);
|
|
4509
5135
|
};
|
|
4510
5136
|
pushObj(pkg?.dependencies);
|
|
@@ -4532,14 +5158,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
4532
5158
|
function collectUnsupportedSignals(files) {
|
|
4533
5159
|
return [
|
|
4534
5160
|
...new Set(
|
|
4535
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
5161
|
+
files.map((f) => UNSUPPORTED_MARKERS[path34.basename(f)]).filter((s) => Boolean(s))
|
|
4536
5162
|
)
|
|
4537
5163
|
].sort();
|
|
4538
5164
|
}
|
|
4539
5165
|
|
|
4540
5166
|
// src/scanner/render.ts
|
|
4541
5167
|
import { readFile } from "node:fs/promises";
|
|
4542
|
-
import
|
|
5168
|
+
import path35 from "node:path";
|
|
4543
5169
|
async function buildContentBlob(root, files) {
|
|
4544
5170
|
const candidates = files.filter(
|
|
4545
5171
|
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
@@ -4547,7 +5173,7 @@ async function buildContentBlob(root, files) {
|
|
|
4547
5173
|
const slice = candidates.slice(0, 300);
|
|
4548
5174
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
4549
5175
|
try {
|
|
4550
|
-
return await readFile(
|
|
5176
|
+
return await readFile(path35.join(root, rel), "utf8");
|
|
4551
5177
|
} catch {
|
|
4552
5178
|
return "";
|
|
4553
5179
|
}
|
|
@@ -4594,6 +5220,11 @@ var SAFE_FILES = [
|
|
|
4594
5220
|
"pnpm-lock.yaml",
|
|
4595
5221
|
"composer.json",
|
|
4596
5222
|
"composer.lock",
|
|
5223
|
+
// Workspace/meta-repo root markers — presence only, drives the 'workspace' role
|
|
5224
|
+
// (src/scanner/detection-registry.ts). Same files resolveWorkspaceForWorktree()
|
|
5225
|
+
// and readMembers() already treat as the workspace-root signal.
|
|
5226
|
+
WORKSPACE_FILE,
|
|
5227
|
+
REPOS_MANIFEST_FILE,
|
|
4597
5228
|
"nx.json",
|
|
4598
5229
|
"turbo.json",
|
|
4599
5230
|
"tsconfig.json",
|
|
@@ -4634,8 +5265,8 @@ var SAFE_FILES = [
|
|
|
4634
5265
|
"Gemfile"
|
|
4635
5266
|
];
|
|
4636
5267
|
async function scanProject(root) {
|
|
4637
|
-
const pkg = await readJson(
|
|
4638
|
-
const composer = await readJson(
|
|
5268
|
+
const pkg = await readJson(path36.join(root, "package.json"));
|
|
5269
|
+
const composer = await readJson(path36.join(root, "composer.json"));
|
|
4639
5270
|
const files = await listFiles(root, SAFE_FILES);
|
|
4640
5271
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
4641
5272
|
const deps = dependencySet(pkg, composer);
|
|
@@ -4649,7 +5280,7 @@ async function scanProject(root) {
|
|
|
4649
5280
|
const warnings = [];
|
|
4650
5281
|
const securityRisks = [];
|
|
4651
5282
|
const crossRepoHints = [];
|
|
4652
|
-
const nodeEngine =
|
|
5283
|
+
const nodeEngine = isRecord2(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
|
|
4653
5284
|
if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
|
|
4654
5285
|
warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
|
|
4655
5286
|
}
|
|
@@ -4659,10 +5290,10 @@ async function scanProject(root) {
|
|
|
4659
5290
|
crossRepoHints.push("Monorepo orchestration detected");
|
|
4660
5291
|
const unsupportedSignals = collectUnsupportedSignals(safeFiles);
|
|
4661
5292
|
const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
|
|
5293
|
+
const repoNameFallback = pkg?.name ? String(pkg.name) : path36.basename((await resolveRoots(root)).mainRoot);
|
|
4662
5294
|
const context = {
|
|
4663
5295
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4664
|
-
|
|
4665
|
-
repoName: String(pkg?.name ?? path31.basename(root)),
|
|
5296
|
+
repoName: repoNameFallback,
|
|
4666
5297
|
packageManager,
|
|
4667
5298
|
repoRoles: roles,
|
|
4668
5299
|
detectedStacks: stacks,
|
|
@@ -4680,10 +5311,18 @@ async function scanProject(root) {
|
|
|
4680
5311
|
|
|
4681
5312
|
// src/scanner/read-context.ts
|
|
4682
5313
|
async function isCacheFresh(root, cachePath) {
|
|
4683
|
-
const
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
5314
|
+
const markerPaths = [
|
|
5315
|
+
path37.join(root, "package.json"),
|
|
5316
|
+
path37.join(root, WORKSPACE_FILE),
|
|
5317
|
+
path37.join(root, REPOS_MANIFEST_FILE)
|
|
5318
|
+
];
|
|
5319
|
+
const cacheStat = await fs28.stat(cachePath);
|
|
5320
|
+
for (const markerPath of markerPaths) {
|
|
5321
|
+
if (!await fs28.pathExists(markerPath)) continue;
|
|
5322
|
+
const markerStat = await fs28.stat(markerPath);
|
|
5323
|
+
if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
|
|
5324
|
+
}
|
|
5325
|
+
return true;
|
|
4687
5326
|
}
|
|
4688
5327
|
async function readContextOrScan(root) {
|
|
4689
5328
|
const cachePath = hausPath(root, "context-map.json");
|
|
@@ -4781,7 +5420,7 @@ async function runDoctor(options) {
|
|
|
4781
5420
|
} else {
|
|
4782
5421
|
ok(`- HOOKS OK: ${hooks.message}`);
|
|
4783
5422
|
}
|
|
4784
|
-
const rootClaudeMdPath =
|
|
5423
|
+
const rootClaudeMdPath = path38.join(root, "CLAUDE.md");
|
|
4785
5424
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
4786
5425
|
if (!rootClaudeMdContent) {
|
|
4787
5426
|
flag(
|
|
@@ -4809,7 +5448,7 @@ async function runDoctor(options) {
|
|
|
4809
5448
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
4810
5449
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
4811
5450
|
for (const target of importTargets) {
|
|
4812
|
-
if (!await
|
|
5451
|
+
if (!await fs29.pathExists(hausPath(root, target))) {
|
|
4813
5452
|
flag(
|
|
4814
5453
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
4815
5454
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -4820,7 +5459,7 @@ async function runDoctor(options) {
|
|
|
4820
5459
|
}
|
|
4821
5460
|
}
|
|
4822
5461
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
4823
|
-
const workflowExists = await
|
|
5462
|
+
const workflowExists = await fs29.pathExists(workflowPath);
|
|
4824
5463
|
if (!workflowExists) {
|
|
4825
5464
|
flag(
|
|
4826
5465
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -4835,15 +5474,15 @@ async function runDoctor(options) {
|
|
|
4835
5474
|
} else {
|
|
4836
5475
|
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
4837
5476
|
const bodyContent = workflowContent.slice(firstLine.length + 1);
|
|
4838
|
-
const cachePath =
|
|
4839
|
-
const bundledPath =
|
|
5477
|
+
const cachePath = path38.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
5478
|
+
const bundledPath = path38.join(
|
|
4840
5479
|
packageRoot(),
|
|
4841
5480
|
"library",
|
|
4842
5481
|
"global",
|
|
4843
5482
|
"templates",
|
|
4844
5483
|
"agentic-workflow-standard.md"
|
|
4845
5484
|
);
|
|
4846
|
-
const templatePath = await
|
|
5485
|
+
const templatePath = await fs29.pathExists(cachePath) ? cachePath : bundledPath;
|
|
4847
5486
|
const templateContent = await readText(templatePath);
|
|
4848
5487
|
const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
|
|
4849
5488
|
if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
|
|
@@ -4864,7 +5503,7 @@ async function runDoctor(options) {
|
|
|
4864
5503
|
}
|
|
4865
5504
|
}
|
|
4866
5505
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
4867
|
-
const workflowConfigExists = await
|
|
5506
|
+
const workflowConfigExists = await fs29.pathExists(workflowConfigPath);
|
|
4868
5507
|
if (!workflowConfigExists) {
|
|
4869
5508
|
flag(
|
|
4870
5509
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -4872,7 +5511,7 @@ async function runDoctor(options) {
|
|
|
4872
5511
|
"haus apply --write"
|
|
4873
5512
|
);
|
|
4874
5513
|
} else {
|
|
4875
|
-
const cfg = await
|
|
5514
|
+
const cfg = await fs29.readFile(workflowConfigPath, "utf8");
|
|
4876
5515
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
4877
5516
|
if (unfilled > 0) {
|
|
4878
5517
|
suggest(
|
|
@@ -4885,7 +5524,7 @@ async function runDoctor(options) {
|
|
|
4885
5524
|
}
|
|
4886
5525
|
}
|
|
4887
5526
|
if (workflowExists) {
|
|
4888
|
-
const prettierIgnore = await readText(
|
|
5527
|
+
const prettierIgnore = await readText(path38.join(root, ".prettierignore")) ?? "";
|
|
4889
5528
|
const lines = prettierIgnore.split("\n");
|
|
4890
5529
|
const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
|
|
4891
5530
|
if (missing.length > 0) {
|
|
@@ -4898,6 +5537,29 @@ async function runDoctor(options) {
|
|
|
4898
5537
|
ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
|
|
4899
5538
|
}
|
|
4900
5539
|
}
|
|
5540
|
+
const trackedArtifacts = await listTrackedArtifactPaths(root);
|
|
5541
|
+
if (trackedArtifacts.length > 0) {
|
|
5542
|
+
flag(
|
|
5543
|
+
`- GITIGNORE: ${trackedArtifacts.length} machine-local scan artifact(s) still tracked in git (${trackedArtifacts.join(", ")})`,
|
|
5544
|
+
`${trackedArtifacts.length} machine-local scan artifact(s) are still tracked in git`,
|
|
5545
|
+
`git rm --cached ${trackedArtifacts.join(" ")} (or run \`haus apply --write\` to do this and update .gitignore for you)`
|
|
5546
|
+
);
|
|
5547
|
+
} else {
|
|
5548
|
+
ok("- GITIGNORE: OK (no machine-local scan artifacts tracked in git)");
|
|
5549
|
+
}
|
|
5550
|
+
const gitignoredHausPaths = await findGitignoredHausPaths(root);
|
|
5551
|
+
if (gitignoredHausPaths.length > 0) {
|
|
5552
|
+
const dirs = summarizeGitignoredHausDirs(gitignoredHausPaths);
|
|
5553
|
+
const dirList = dirs.join(" and ");
|
|
5554
|
+
const verb = dirs.length === 1 ? "is" : "are";
|
|
5555
|
+
flag(
|
|
5556
|
+
`- GITIGNORE (haus content): ${dirList} ${verb} gitignored \u2014 installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
|
|
5557
|
+
`${dirList} ${verb} gitignored, so haus-installed content isn't visible to git-tracked state`,
|
|
5558
|
+
"remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored)"
|
|
5559
|
+
);
|
|
5560
|
+
} else {
|
|
5561
|
+
ok("- GITIGNORE (haus content): OK (.claude/ and .haus-workflow/ are tracked)");
|
|
5562
|
+
}
|
|
4901
5563
|
for (const finding of await auditDecisionsLayout(root)) {
|
|
4902
5564
|
if (finding.level === "ok") ok(finding.line);
|
|
4903
5565
|
else suggest(finding.line, finding.sentence, finding.fix);
|
|
@@ -4935,7 +5597,7 @@ async function runDoctor(options) {
|
|
|
4935
5597
|
);
|
|
4936
5598
|
}
|
|
4937
5599
|
}
|
|
4938
|
-
const pkgJson = await readJson(
|
|
5600
|
+
const pkgJson = await readJson(path38.join(packageRoot(), "package.json"));
|
|
4939
5601
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
4940
5602
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
4941
5603
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -4968,12 +5630,12 @@ async function runDoctor(options) {
|
|
|
4968
5630
|
}
|
|
4969
5631
|
|
|
4970
5632
|
// src/commands/update.ts
|
|
4971
|
-
import
|
|
5633
|
+
import path41 from "node:path";
|
|
4972
5634
|
|
|
4973
5635
|
// src/claude/refresh-project.ts
|
|
4974
|
-
import
|
|
5636
|
+
import fs30 from "fs-extra";
|
|
4975
5637
|
async function isHausProject(root) {
|
|
4976
|
-
if (await
|
|
5638
|
+
if (await fs30.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
4977
5639
|
const result = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
4978
5640
|
if (result.status === "ok") return result.value._haus != null;
|
|
4979
5641
|
return false;
|
|
@@ -4985,8 +5647,8 @@ async function refreshProjectApply(root) {
|
|
|
4985
5647
|
|
|
4986
5648
|
// src/install/apply.ts
|
|
4987
5649
|
import crypto2 from "node:crypto";
|
|
4988
|
-
import
|
|
4989
|
-
import
|
|
5650
|
+
import path39 from "node:path";
|
|
5651
|
+
import fs31 from "fs-extra";
|
|
4990
5652
|
|
|
4991
5653
|
// src/install/header.ts
|
|
4992
5654
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -5072,57 +5734,57 @@ function hashContent(content2) {
|
|
|
5072
5734
|
}
|
|
5073
5735
|
function sourceVersion() {
|
|
5074
5736
|
try {
|
|
5075
|
-
const pkgPath =
|
|
5076
|
-
const pkg = JSON.parse(
|
|
5737
|
+
const pkgPath = path39.join(packageRoot(), "package.json");
|
|
5738
|
+
const pkg = JSON.parse(fs31.readFileSync(pkgPath, "utf8"));
|
|
5077
5739
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
5078
5740
|
} catch {
|
|
5079
5741
|
return "haus@0.0.0";
|
|
5080
5742
|
}
|
|
5081
5743
|
}
|
|
5082
5744
|
function globalSrcDir() {
|
|
5083
|
-
return
|
|
5745
|
+
return path39.join(packageRoot(), "library", "global");
|
|
5084
5746
|
}
|
|
5085
5747
|
function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
5086
|
-
const skillDir =
|
|
5748
|
+
const skillDir = path39.join(skillsDir, skillName);
|
|
5087
5749
|
const entries = [];
|
|
5088
|
-
for (const rel of
|
|
5750
|
+
for (const rel of fs31.readdirSync(skillDir, { recursive: true })) {
|
|
5089
5751
|
if (rel === "SKILL.md") continue;
|
|
5090
|
-
const abs =
|
|
5091
|
-
if (!
|
|
5092
|
-
const posixRel = rel.split(
|
|
5752
|
+
const abs = path39.join(skillDir, rel);
|
|
5753
|
+
if (!fs31.statSync(abs).isFile()) continue;
|
|
5754
|
+
const posixRel = rel.split(path39.sep).join("/");
|
|
5093
5755
|
entries.push({
|
|
5094
5756
|
stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
|
|
5095
|
-
srcRelPath:
|
|
5096
|
-
destPath:
|
|
5757
|
+
srcRelPath: path39.join("library", "global", "skills", skillName, rel),
|
|
5758
|
+
destPath: path39.join(claudeDir, "skills", skillName, rel)
|
|
5097
5759
|
});
|
|
5098
5760
|
}
|
|
5099
5761
|
return entries;
|
|
5100
5762
|
}
|
|
5101
5763
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
5102
5764
|
const entries = [];
|
|
5103
|
-
const skillsDir =
|
|
5104
|
-
if (
|
|
5105
|
-
for (const skillName of
|
|
5106
|
-
const skillFile =
|
|
5107
|
-
if (
|
|
5765
|
+
const skillsDir = path39.join(srcDir, "skills");
|
|
5766
|
+
if (fs31.pathExistsSync(skillsDir)) {
|
|
5767
|
+
for (const skillName of fs31.readdirSync(skillsDir)) {
|
|
5768
|
+
const skillFile = path39.join(skillsDir, skillName, "SKILL.md");
|
|
5769
|
+
if (fs31.pathExistsSync(skillFile)) {
|
|
5108
5770
|
entries.push({
|
|
5109
5771
|
stableId: `skill.${skillName}`,
|
|
5110
|
-
srcRelPath:
|
|
5111
|
-
destPath:
|
|
5772
|
+
srcRelPath: path39.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
5773
|
+
destPath: path39.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
5112
5774
|
});
|
|
5113
5775
|
entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
|
|
5114
5776
|
}
|
|
5115
5777
|
}
|
|
5116
5778
|
}
|
|
5117
|
-
const commandsDir =
|
|
5118
|
-
if (
|
|
5119
|
-
for (const fileName of
|
|
5779
|
+
const commandsDir = path39.join(srcDir, "commands");
|
|
5780
|
+
if (fs31.pathExistsSync(commandsDir)) {
|
|
5781
|
+
for (const fileName of fs31.readdirSync(commandsDir)) {
|
|
5120
5782
|
if (!fileName.endsWith(".md")) continue;
|
|
5121
5783
|
const commandName = fileName.slice(0, -".md".length);
|
|
5122
5784
|
entries.push({
|
|
5123
5785
|
stableId: `command.${commandName}`,
|
|
5124
|
-
srcRelPath:
|
|
5125
|
-
destPath:
|
|
5786
|
+
srcRelPath: path39.join("library", "global", "commands", fileName),
|
|
5787
|
+
destPath: path39.join(claudeDir, "commands", fileName)
|
|
5126
5788
|
});
|
|
5127
5789
|
}
|
|
5128
5790
|
}
|
|
@@ -5148,7 +5810,7 @@ async function applyInstall(options = {}) {
|
|
|
5148
5810
|
};
|
|
5149
5811
|
const manifestFiles = [];
|
|
5150
5812
|
for (const entry of sourceFiles) {
|
|
5151
|
-
const srcPath =
|
|
5813
|
+
const srcPath = path39.join(packageRoot(), entry.srcRelPath);
|
|
5152
5814
|
const rawContent = await readText(srcPath);
|
|
5153
5815
|
if (rawContent === void 0) {
|
|
5154
5816
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
@@ -5168,7 +5830,7 @@ async function applyInstall(options = {}) {
|
|
|
5168
5830
|
}
|
|
5169
5831
|
continue;
|
|
5170
5832
|
}
|
|
5171
|
-
const destExists =
|
|
5833
|
+
const destExists = fs31.pathExistsSync(entry.destPath);
|
|
5172
5834
|
if (destExists) {
|
|
5173
5835
|
const currentContent = await readText(entry.destPath);
|
|
5174
5836
|
if (currentContent !== void 0) {
|
|
@@ -5208,7 +5870,7 @@ async function applyInstall(options = {}) {
|
|
|
5208
5870
|
}
|
|
5209
5871
|
let mergedSettings;
|
|
5210
5872
|
if (!check) {
|
|
5211
|
-
const fragmentPath =
|
|
5873
|
+
const fragmentPath = path39.join(srcDir, "settings-fragments", "hooks.json");
|
|
5212
5874
|
const fragments = await loadHooksFragment(fragmentPath);
|
|
5213
5875
|
const settings = await readSettings();
|
|
5214
5876
|
const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
|
|
@@ -5222,13 +5884,13 @@ async function applyInstall(options = {}) {
|
|
|
5222
5884
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
5223
5885
|
for (const entry of existingManifest.files) {
|
|
5224
5886
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
5225
|
-
if (!
|
|
5887
|
+
if (!fs31.pathExistsSync(entry.destPath)) continue;
|
|
5226
5888
|
const content2 = await readText(entry.destPath);
|
|
5227
5889
|
if (!content2) continue;
|
|
5228
5890
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
5229
5891
|
const currentHash = hashContent(content2);
|
|
5230
5892
|
if (hasHeader && currentHash === entry.hash) {
|
|
5231
|
-
if (!dryRun) await
|
|
5893
|
+
if (!dryRun) await fs31.remove(entry.destPath);
|
|
5232
5894
|
result.deleted.push(entry.destPath);
|
|
5233
5895
|
} else {
|
|
5234
5896
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -5297,7 +5959,7 @@ function summarizeLockDiff(before, after) {
|
|
|
5297
5959
|
|
|
5298
5960
|
// src/update/lockfile.ts
|
|
5299
5961
|
import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
|
|
5300
|
-
import
|
|
5962
|
+
import path40 from "node:path";
|
|
5301
5963
|
async function readLockSummary(root) {
|
|
5302
5964
|
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
5303
5965
|
const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
|
|
@@ -5333,7 +5995,7 @@ async function applyLock(root) {
|
|
|
5333
5995
|
try {
|
|
5334
5996
|
const backupDir = hausPath(root, "backups");
|
|
5335
5997
|
await mkdir(backupDir, { recursive: true });
|
|
5336
|
-
await copyFile(lockPath,
|
|
5998
|
+
await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
5337
5999
|
} catch {
|
|
5338
6000
|
}
|
|
5339
6001
|
const enriched = await Promise.all(
|
|
@@ -5355,7 +6017,7 @@ function diffLock(before, after) {
|
|
|
5355
6017
|
}
|
|
5356
6018
|
async function hasLocalOverrides(root) {
|
|
5357
6019
|
try {
|
|
5358
|
-
await readFile2(
|
|
6020
|
+
await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
|
|
5359
6021
|
return true;
|
|
5360
6022
|
} catch {
|
|
5361
6023
|
return false;
|
|
@@ -5371,7 +6033,7 @@ async function runUpdate(options) {
|
|
|
5371
6033
|
return;
|
|
5372
6034
|
}
|
|
5373
6035
|
if (options.check) {
|
|
5374
|
-
const pkgJson2 = await readJson(
|
|
6036
|
+
const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
|
|
5375
6037
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
5376
6038
|
const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
|
|
5377
6039
|
options.fast ? fastLockStatus(root) : checkLock(root),
|
|
@@ -5406,7 +6068,7 @@ async function runUpdate(options) {
|
|
|
5406
6068
|
if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
|
|
5407
6069
|
return;
|
|
5408
6070
|
}
|
|
5409
|
-
const pkgJson = await readJson(
|
|
6071
|
+
const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
|
|
5410
6072
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
5411
6073
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
5412
6074
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -5448,7 +6110,7 @@ async function runFromHookCheck(root) {
|
|
|
5448
6110
|
try {
|
|
5449
6111
|
const summary = await readLockSummary(root);
|
|
5450
6112
|
if (summary.count === 0) return;
|
|
5451
|
-
const pkgJson = await readJson(
|
|
6113
|
+
const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
|
|
5452
6114
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
5453
6115
|
const [npmVersion, latestCatalogTag] = await Promise.all([
|
|
5454
6116
|
fetchNpmVersionStatus(currentVersion),
|
|
@@ -5598,7 +6260,7 @@ async function runCiGate(options = {}) {
|
|
|
5598
6260
|
// src/commands/clone.ts
|
|
5599
6261
|
init_exec();
|
|
5600
6262
|
import { existsSync as existsSync2 } from "node:fs";
|
|
5601
|
-
import
|
|
6263
|
+
import path42 from "node:path";
|
|
5602
6264
|
var GIT_LOCATION_VARS = [
|
|
5603
6265
|
"GIT_DIR",
|
|
5604
6266
|
"GIT_WORK_TREE",
|
|
@@ -5648,11 +6310,11 @@ async function runClone(url, opts = {}) {
|
|
|
5648
6310
|
process.exitCode = 1;
|
|
5649
6311
|
return;
|
|
5650
6312
|
}
|
|
5651
|
-
const target =
|
|
6313
|
+
const target = path42.resolve(opts.dir?.trim() || repoNameFromUrl(url));
|
|
5652
6314
|
if (existsSync2(target)) {
|
|
5653
6315
|
const check = await checkExistingTarget(target, url);
|
|
5654
6316
|
if (check.kind === "matches") {
|
|
5655
|
-
log(`\u2022 ${
|
|
6317
|
+
log(`\u2022 ${path42.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
|
|
5656
6318
|
return;
|
|
5657
6319
|
}
|
|
5658
6320
|
const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
|
|
@@ -5692,7 +6354,7 @@ function readHookPayload() {
|
|
|
5692
6354
|
if (!raw) return { payload: {}, malformed: false };
|
|
5693
6355
|
try {
|
|
5694
6356
|
const parsed = JSON.parse(raw);
|
|
5695
|
-
return { payload:
|
|
6357
|
+
return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
|
|
5696
6358
|
} catch {
|
|
5697
6359
|
return { payload: {}, malformed: true };
|
|
5698
6360
|
}
|
|
@@ -5731,7 +6393,7 @@ function readPayload(options) {
|
|
|
5731
6393
|
if (options.stdinPayload != null) {
|
|
5732
6394
|
try {
|
|
5733
6395
|
const parsed = JSON.parse(options.stdinPayload);
|
|
5734
|
-
return { payload:
|
|
6396
|
+
return { payload: isRecord2(parsed) ? parsed : {}, malformed: false };
|
|
5735
6397
|
} catch {
|
|
5736
6398
|
return { payload: {}, malformed: true };
|
|
5737
6399
|
}
|
|
@@ -5744,7 +6406,7 @@ async function runDecisionsGuard(options = {}) {
|
|
|
5744
6406
|
emitPreToolUseDeny("Malformed hook payload");
|
|
5745
6407
|
return;
|
|
5746
6408
|
}
|
|
5747
|
-
const toolInput =
|
|
6409
|
+
const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
|
|
5748
6410
|
const command = String(toolInput.command ?? "");
|
|
5749
6411
|
if (!PR_CREATE_PATTERN.test(command)) return;
|
|
5750
6412
|
const root = process.cwd();
|
|
@@ -5756,13 +6418,13 @@ async function runDecisionsGuard(options = {}) {
|
|
|
5756
6418
|
const result = await runDecisionsCheck(root, { range, commitMessages });
|
|
5757
6419
|
if (result.triggered && !result.satisfied) {
|
|
5758
6420
|
emitPreToolUseDeny(
|
|
5759
|
-
`I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under docs/decisions/, then retry.`
|
|
6421
|
+
`I didn't run that \u2014 this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under docs/decisions/, then retry \u2014 or use [adr-skip] for non-security changes.`
|
|
5760
6422
|
);
|
|
5761
6423
|
}
|
|
5762
6424
|
}
|
|
5763
6425
|
|
|
5764
6426
|
// src/commands/decisions.ts
|
|
5765
|
-
import
|
|
6427
|
+
import path43 from "node:path";
|
|
5766
6428
|
|
|
5767
6429
|
// src/decisions/next-number.ts
|
|
5768
6430
|
async function nextDecisionNumber(root) {
|
|
@@ -5805,10 +6467,9 @@ _(Why this over alternatives \u2014 the line agents read for "why".)_
|
|
|
5805
6467
|
`;
|
|
5806
6468
|
}
|
|
5807
6469
|
async function runDecisionsSuggest(root, opts = {}) {
|
|
5808
|
-
const stats = await collectDiffStats(root, { staged: true });
|
|
5809
6470
|
const stagedCheck = await runDecisionsCheck(root, { staged: true });
|
|
5810
|
-
const
|
|
5811
|
-
const triggered =
|
|
6471
|
+
const unstagedCheck = await runDecisionsCheck(root, {});
|
|
6472
|
+
const triggered = stagedCheck.triggered || unstagedCheck.triggered;
|
|
5812
6473
|
if (!triggered) return;
|
|
5813
6474
|
if (stagedCheck.triggered && stagedCheck.satisfied) return;
|
|
5814
6475
|
const number = await nextDecisionNumber(root);
|
|
@@ -5859,7 +6520,7 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
5859
6520
|
}
|
|
5860
6521
|
for (const reason of result.reasons) warn(reason);
|
|
5861
6522
|
error(
|
|
5862
|
-
|
|
6523
|
+
`decisions gate: decision-worthy change without a valid ADR under docs/decisions/. Why: ${result.reasons.filter((r) => !r.startsWith("no new")).join("; ") || "see above"}. Run \`haus decisions suggest\`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification (ignored for security paths).`
|
|
5863
6524
|
);
|
|
5864
6525
|
process.exitCode = 1;
|
|
5865
6526
|
return;
|
|
@@ -5873,7 +6534,7 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
5873
6534
|
return;
|
|
5874
6535
|
}
|
|
5875
6536
|
if (sub === "validate") {
|
|
5876
|
-
const target = arg ?
|
|
6537
|
+
const target = arg ? path43.resolve(root, arg) : void 0;
|
|
5877
6538
|
if (!target) {
|
|
5878
6539
|
error("usage: haus decisions validate <path-to-decision.md>");
|
|
5879
6540
|
process.exitCode = 1;
|
|
@@ -6125,7 +6786,7 @@ async function runGuard(kind, _options) {
|
|
|
6125
6786
|
emitPreToolUseDeny("Malformed hook payload");
|
|
6126
6787
|
return;
|
|
6127
6788
|
}
|
|
6128
|
-
const toolInput =
|
|
6789
|
+
const toolInput = isRecord2(payload.tool_input) ? payload.tool_input : {};
|
|
6129
6790
|
if (kind === "file-access") {
|
|
6130
6791
|
const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
|
|
6131
6792
|
const reason2 = guardFileAccess(candidate);
|
|
@@ -6142,8 +6803,8 @@ async function runGuard(kind, _options) {
|
|
|
6142
6803
|
}
|
|
6143
6804
|
|
|
6144
6805
|
// src/commands/init.ts
|
|
6145
|
-
import
|
|
6146
|
-
import
|
|
6806
|
+
import path45 from "node:path";
|
|
6807
|
+
import fs32 from "fs-extra";
|
|
6147
6808
|
|
|
6148
6809
|
// src/recommender/git-signal.ts
|
|
6149
6810
|
init_exec();
|
|
@@ -6206,11 +6867,9 @@ function describeRequiresAny(clauses) {
|
|
|
6206
6867
|
return "unknown";
|
|
6207
6868
|
}).join(" | ");
|
|
6208
6869
|
}
|
|
6209
|
-
function mergeRecommendationWarnings(context) {
|
|
6870
|
+
function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
|
|
6210
6871
|
const markers = context.unsupportedSignals?.join(", ");
|
|
6211
|
-
const statusLines = context.detectionStatus === "unknown" ? [
|
|
6212
|
-
markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support. Only stack-agnostic workflow and security guidance is applied.` : "Stack not recognised \u2014 no supported framework detected. Only stack-agnostic workflow and security guidance is applied."
|
|
6213
|
-
] : context.detectionStatus === "partial" && markers ? [
|
|
6872
|
+
const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
|
|
6214
6873
|
`Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
|
|
6215
6874
|
] : [];
|
|
6216
6875
|
const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
|
|
@@ -6429,11 +7088,12 @@ async function recommend(root, context, opts = {}) {
|
|
|
6429
7088
|
const skippedRules = skipped.length;
|
|
6430
7089
|
const estimatedContextTokens = estimateContextTokens(selectedRules);
|
|
6431
7090
|
const estimatedTokenReductionPct = tokenReductionPct(selectedRules, skippedRules);
|
|
7091
|
+
const { isLinkedWorktree } = await resolveRoots(root);
|
|
6432
7092
|
return {
|
|
6433
7093
|
recommended,
|
|
6434
7094
|
skipped,
|
|
6435
7095
|
optInEligible,
|
|
6436
|
-
warnings: [...mergeRecommendationWarnings(context), ...includeWarnings],
|
|
7096
|
+
warnings: [...mergeRecommendationWarnings(context, isLinkedWorktree), ...includeWarnings],
|
|
6437
7097
|
estimatedContextTokens,
|
|
6438
7098
|
selectedRules,
|
|
6439
7099
|
skippedRules,
|
|
@@ -6610,9 +7270,162 @@ function buildOptInEligible(items, skipped) {
|
|
|
6610
7270
|
return eligible;
|
|
6611
7271
|
}
|
|
6612
7272
|
|
|
7273
|
+
// src/commands/workspace/discover.ts
|
|
7274
|
+
import path44 from "node:path";
|
|
7275
|
+
import fg4 from "fast-glob";
|
|
7276
|
+
import YAML2 from "yaml";
|
|
7277
|
+
var DEFAULT_MAX_DEPTH = 3;
|
|
7278
|
+
var GIT_MARKER = ["**/.git"];
|
|
7279
|
+
var MANIFEST_MARKERS = [
|
|
7280
|
+
"**/package.json",
|
|
7281
|
+
"**/composer.json",
|
|
7282
|
+
"**/*.csproj",
|
|
7283
|
+
"**/*.sln",
|
|
7284
|
+
"**/*.fsproj",
|
|
7285
|
+
// .NET
|
|
7286
|
+
"**/pom.xml",
|
|
7287
|
+
"**/build.gradle*",
|
|
7288
|
+
// Java
|
|
7289
|
+
"**/Gemfile"
|
|
7290
|
+
// Ruby
|
|
7291
|
+
];
|
|
7292
|
+
var IGNORE = ["**/node_modules/**", "**/vendor/**", "**/dist/**", "**/.haus-workflow/**"];
|
|
7293
|
+
function isDescendant(child, ancestor) {
|
|
7294
|
+
if (ancestor === ".") return child !== ".";
|
|
7295
|
+
return child === ancestor ? false : child.startsWith(`${ancestor}/`);
|
|
7296
|
+
}
|
|
7297
|
+
async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
7298
|
+
const commonOpts = {
|
|
7299
|
+
cwd: workspaceRoot,
|
|
7300
|
+
dot: true,
|
|
7301
|
+
onlyFiles: false,
|
|
7302
|
+
deep: maxDepth,
|
|
7303
|
+
followSymbolicLinks: false,
|
|
7304
|
+
// Discovery walks arbitrary directories under the workspace root; an
|
|
7305
|
+
// unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
|
|
7306
|
+
suppressErrors: true
|
|
7307
|
+
};
|
|
7308
|
+
const [gitMatches, manifestMatches] = await Promise.all([
|
|
7309
|
+
fg4(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
|
|
7310
|
+
fg4(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
|
|
7311
|
+
]);
|
|
7312
|
+
const gitDirs = /* @__PURE__ */ new Set();
|
|
7313
|
+
const manifestDirs = /* @__PURE__ */ new Set();
|
|
7314
|
+
for (const match of gitMatches) {
|
|
7315
|
+
gitDirs.add(path44.posix.dirname(match));
|
|
7316
|
+
}
|
|
7317
|
+
for (const match of manifestMatches) {
|
|
7318
|
+
manifestDirs.add(path44.posix.dirname(match));
|
|
7319
|
+
}
|
|
7320
|
+
const repoRoots = [...gitDirs];
|
|
7321
|
+
const manifestSorted = [...manifestDirs].sort(
|
|
7322
|
+
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
|
|
7323
|
+
);
|
|
7324
|
+
for (const dir of manifestSorted) {
|
|
7325
|
+
if (gitDirs.has(dir)) continue;
|
|
7326
|
+
if (repoRoots.some((root) => isDescendant(dir, root))) continue;
|
|
7327
|
+
repoRoots.push(dir);
|
|
7328
|
+
}
|
|
7329
|
+
repoRoots.sort((a, b) => a.localeCompare(b));
|
|
7330
|
+
return repoRoots;
|
|
7331
|
+
}
|
|
7332
|
+
async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
7333
|
+
const repoRoots = await findRepoRoots(workspaceRoot, maxDepth);
|
|
7334
|
+
return mapWithConcurrency(repoRoots, async (relDir) => {
|
|
7335
|
+
const absDir = path44.resolve(workspaceRoot, relDir);
|
|
7336
|
+
const pkg = await readJson(path44.join(absDir, "package.json"));
|
|
7337
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path44.basename(relDir === "." ? workspaceRoot : absDir);
|
|
7338
|
+
let role = "auto";
|
|
7339
|
+
try {
|
|
7340
|
+
const scan = await scanProject(absDir);
|
|
7341
|
+
if (scan.repoRoles.length > 0) role = scan.repoRoles.join("+");
|
|
7342
|
+
} catch {
|
|
7343
|
+
}
|
|
7344
|
+
return { name, path: relDir === "." ? "." : relDir, role };
|
|
7345
|
+
});
|
|
7346
|
+
}
|
|
7347
|
+
function mergeWorkspaceConfig(existing, discovered, opts = {}) {
|
|
7348
|
+
const existingRepos = existing?.repos ?? [];
|
|
7349
|
+
const byPath = new Map(existingRepos.map((r) => [r.path, r]));
|
|
7350
|
+
for (const repo of discovered) {
|
|
7351
|
+
if (!byPath.has(repo.path)) {
|
|
7352
|
+
byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
const ordered = [];
|
|
7356
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7357
|
+
for (const repo of existingRepos) {
|
|
7358
|
+
ordered.push(byPath.get(repo.path));
|
|
7359
|
+
seen.add(repo.path);
|
|
7360
|
+
}
|
|
7361
|
+
for (const repo of discovered) {
|
|
7362
|
+
if (seen.has(repo.path)) continue;
|
|
7363
|
+
ordered.push(byPath.get(repo.path));
|
|
7364
|
+
seen.add(repo.path);
|
|
7365
|
+
}
|
|
7366
|
+
return {
|
|
7367
|
+
client: opts.client ?? existing?.client ?? "unknown",
|
|
7368
|
+
repos: ordered,
|
|
7369
|
+
relationships: existing?.relationships ?? []
|
|
7370
|
+
};
|
|
7371
|
+
}
|
|
7372
|
+
function renderWorkspaceYaml(config) {
|
|
7373
|
+
return YAML2.stringify({
|
|
7374
|
+
client: config.client,
|
|
7375
|
+
repos: config.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
|
|
7376
|
+
relationships: config.relationships
|
|
7377
|
+
});
|
|
7378
|
+
}
|
|
7379
|
+
async function runDiscover(workspaceRoot, opts = {}) {
|
|
7380
|
+
const yamlPath = path44.join(workspaceRoot, "haus.workspace.yaml");
|
|
7381
|
+
const existingText = await readText(yamlPath);
|
|
7382
|
+
const existing = parseWorkspaceConfig(existingText);
|
|
7383
|
+
if (existingText && !existing) {
|
|
7384
|
+
error(
|
|
7385
|
+
"Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
|
|
7386
|
+
);
|
|
7387
|
+
process.exitCode = 1;
|
|
7388
|
+
return;
|
|
7389
|
+
}
|
|
7390
|
+
const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
|
|
7391
|
+
if (discovered.length === 0) {
|
|
7392
|
+
error("No repos discovered under the workspace root.");
|
|
7393
|
+
process.exitCode = 1;
|
|
7394
|
+
return;
|
|
7395
|
+
}
|
|
7396
|
+
const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
|
|
7397
|
+
const yamlText = renderWorkspaceYaml(merged);
|
|
7398
|
+
if (opts.write) {
|
|
7399
|
+
await writeText(yamlPath, yamlText);
|
|
7400
|
+
if (opts.json) {
|
|
7401
|
+
log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
|
|
7402
|
+
} else {
|
|
7403
|
+
log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
|
|
7404
|
+
}
|
|
7405
|
+
return;
|
|
7406
|
+
}
|
|
7407
|
+
if (opts.json) {
|
|
7408
|
+
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
7409
|
+
return;
|
|
7410
|
+
}
|
|
7411
|
+
log("Proposed haus.workspace.yaml (run with --write to persist):\n");
|
|
7412
|
+
log(yamlText);
|
|
7413
|
+
}
|
|
7414
|
+
|
|
7415
|
+
// src/scanner/sibling-repos.ts
|
|
7416
|
+
var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root \u2014 consider "haus workspace discover" instead of running setup-project/scan per repo.';
|
|
7417
|
+
async function hasMultipleSiblingRepos(root) {
|
|
7418
|
+
try {
|
|
7419
|
+
const repoRoots = await findRepoRoots(root);
|
|
7420
|
+
return repoRoots.filter((relDir) => relDir !== ".").length >= 2;
|
|
7421
|
+
} catch {
|
|
7422
|
+
return false;
|
|
7423
|
+
}
|
|
7424
|
+
}
|
|
7425
|
+
|
|
6613
7426
|
// src/claude/setup-core.ts
|
|
6614
7427
|
async function runSetupCore(root, opts) {
|
|
6615
|
-
const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
|
|
7428
|
+
const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
|
|
6616
7429
|
const say = quiet ? () => {
|
|
6617
7430
|
} : log;
|
|
6618
7431
|
const scanResult = await scanProject(root);
|
|
@@ -6623,8 +7436,35 @@ async function runSetupCore(root, opts) {
|
|
|
6623
7436
|
say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
|
|
6624
7437
|
say(`Package manager: ${scanResult.packageManager}`);
|
|
6625
7438
|
}
|
|
7439
|
+
const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
|
|
6626
7440
|
const context = await readContextOrScan(root);
|
|
6627
7441
|
const recommendation = await recommend(root, context);
|
|
7442
|
+
if (recommendation.recommended.length === 0 && !force) {
|
|
7443
|
+
const warningLines2 = [
|
|
7444
|
+
.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
|
|
7445
|
+
];
|
|
7446
|
+
if (json) {
|
|
7447
|
+
say(JSON.stringify(recommendation, null, 2));
|
|
7448
|
+
} else {
|
|
7449
|
+
say("Haus recommendation ready");
|
|
7450
|
+
say("Recommended: 0");
|
|
7451
|
+
say(`Skipped: ${recommendation.skipped.length}`);
|
|
7452
|
+
say(`Repo: ${context.repoName}`);
|
|
7453
|
+
for (const warning of warningLines2) say(`- WARN: ${warning}`);
|
|
7454
|
+
}
|
|
7455
|
+
const message = "No catalog items matched this project at all (zero signal) \u2014 refusing to write recommendation.json/haus.lock.json. Re-run with `haus setup-project --force` to write anyway.";
|
|
7456
|
+
warn(message);
|
|
7457
|
+
process.exitCode = 1;
|
|
7458
|
+
return {
|
|
7459
|
+
root,
|
|
7460
|
+
repoName: context.repoName,
|
|
7461
|
+
roles: scanResult.repoRoles,
|
|
7462
|
+
recommendedCount: 0,
|
|
7463
|
+
warnings: [...warningLines2, message],
|
|
7464
|
+
hooksOk: false,
|
|
7465
|
+
written: []
|
|
7466
|
+
};
|
|
7467
|
+
}
|
|
6628
7468
|
await writeJson(hausPath(root, "recommendation.json"), recommendation);
|
|
6629
7469
|
if (json) {
|
|
6630
7470
|
say(JSON.stringify(recommendation, null, 2));
|
|
@@ -6634,7 +7474,9 @@ async function runSetupCore(root, opts) {
|
|
|
6634
7474
|
say(`Skipped: ${recommendation.skipped.length}`);
|
|
6635
7475
|
}
|
|
6636
7476
|
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
6637
|
-
const warningLines = [
|
|
7477
|
+
const warningLines = [
|
|
7478
|
+
.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
|
|
7479
|
+
];
|
|
6638
7480
|
say(`Repo: ${context.repoName}`);
|
|
6639
7481
|
for (const warning of warningLines) say(`- WARN: ${warning}`);
|
|
6640
7482
|
const hooksOk = hooks.ok;
|
|
@@ -6699,6 +7541,7 @@ async function runSetupProject(options) {
|
|
|
6699
7541
|
json: options.json,
|
|
6700
7542
|
apply: !options.json,
|
|
6701
7543
|
dryRun: false,
|
|
7544
|
+
force: options.force,
|
|
6702
7545
|
confirm: () => confirm("Approve and write Claude files now?")
|
|
6703
7546
|
});
|
|
6704
7547
|
}
|
|
@@ -6706,8 +7549,8 @@ async function runSetupProject(options) {
|
|
|
6706
7549
|
// src/commands/init.ts
|
|
6707
7550
|
async function runInit(options) {
|
|
6708
7551
|
const root = process.cwd();
|
|
6709
|
-
const hausDir =
|
|
6710
|
-
const alreadyInit = await
|
|
7552
|
+
const hausDir = path45.join(root, ".haus-workflow");
|
|
7553
|
+
const alreadyInit = await fs32.pathExists(hausDir);
|
|
6711
7554
|
if (alreadyInit) {
|
|
6712
7555
|
log("Haus AI already initialized in this project.");
|
|
6713
7556
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -6842,8 +7685,8 @@ async function runRefresh() {
|
|
|
6842
7685
|
}
|
|
6843
7686
|
|
|
6844
7687
|
// src/install/scaffold.ts
|
|
6845
|
-
import
|
|
6846
|
-
import
|
|
7688
|
+
import path46 from "node:path";
|
|
7689
|
+
import fs33 from "fs-extra";
|
|
6847
7690
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
6848
7691
|
const scaffolded = [];
|
|
6849
7692
|
const skipped = [];
|
|
@@ -6856,27 +7699,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
6856
7699
|
skipped.push(name);
|
|
6857
7700
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
6858
7701
|
};
|
|
6859
|
-
const realRoot = await
|
|
7702
|
+
const realRoot = await fs33.realpath(catalogRoot).catch(() => null);
|
|
6860
7703
|
for (const item of items) {
|
|
6861
7704
|
if (item.type !== "config") continue;
|
|
6862
|
-
const sourcePath =
|
|
6863
|
-
const rel =
|
|
6864
|
-
const escapes = rel === "" ||
|
|
7705
|
+
const sourcePath = path46.resolve(catalogRoot, item.path);
|
|
7706
|
+
const rel = path46.relative(catalogRoot, sourcePath);
|
|
7707
|
+
const escapes = rel === "" || path46.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
|
|
6865
7708
|
if (escapes) {
|
|
6866
7709
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
6867
7710
|
continue;
|
|
6868
7711
|
}
|
|
6869
|
-
const realSource = await
|
|
7712
|
+
const realSource = await fs33.realpath(sourcePath).catch(() => null);
|
|
6870
7713
|
if (!realRoot || !realSource) {
|
|
6871
7714
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
6872
7715
|
continue;
|
|
6873
7716
|
}
|
|
6874
|
-
const realRel =
|
|
6875
|
-
if (realRel !== "" && (
|
|
7717
|
+
const realRel = path46.relative(realRoot, realSource);
|
|
7718
|
+
if (realRel !== "" && (path46.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
|
|
6876
7719
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
6877
7720
|
continue;
|
|
6878
7721
|
}
|
|
6879
|
-
const stat = await
|
|
7722
|
+
const stat = await fs33.lstat(sourcePath).catch(() => null);
|
|
6880
7723
|
if (!stat) {
|
|
6881
7724
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
6882
7725
|
continue;
|
|
@@ -6886,14 +7729,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
6886
7729
|
continue;
|
|
6887
7730
|
}
|
|
6888
7731
|
if (stat.isFile()) {
|
|
6889
|
-
const filename =
|
|
6890
|
-
const dest =
|
|
7732
|
+
const filename = path46.basename(sourcePath);
|
|
7733
|
+
const dest = path46.join(projectRoot, filename);
|
|
6891
7734
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
6892
7735
|
} else if (stat.isDirectory()) {
|
|
6893
|
-
const entries = await
|
|
7736
|
+
const entries = await fs33.readdir(sourcePath);
|
|
6894
7737
|
for (const entry of entries) {
|
|
6895
|
-
const src =
|
|
6896
|
-
const dest =
|
|
7738
|
+
const src = path46.join(sourcePath, entry);
|
|
7739
|
+
const dest = path46.join(projectRoot, entry);
|
|
6897
7740
|
record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
|
|
6898
7741
|
}
|
|
6899
7742
|
}
|
|
@@ -6901,42 +7744,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
6901
7744
|
return { scaffolded, skipped, skippedExisting };
|
|
6902
7745
|
}
|
|
6903
7746
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
6904
|
-
const destDisplay =
|
|
6905
|
-
const srcStat = await
|
|
7747
|
+
const destDisplay = path46.relative(projectRoot, dest) || path46.basename(dest);
|
|
7748
|
+
const srcStat = await fs33.lstat(src).catch(() => null);
|
|
6906
7749
|
if (!srcStat) {
|
|
6907
|
-
warn(`Skipping ${
|
|
7750
|
+
warn(`Skipping ${path46.basename(src)}: source not found`);
|
|
6908
7751
|
return "skipped-refused";
|
|
6909
7752
|
}
|
|
6910
7753
|
if (srcStat.isSymbolicLink()) {
|
|
6911
|
-
warn(`Skipping ${
|
|
7754
|
+
warn(`Skipping ${path46.basename(src)}: source is a symlink`);
|
|
6912
7755
|
return "skipped-refused";
|
|
6913
7756
|
}
|
|
6914
|
-
const exists = await
|
|
7757
|
+
const exists = await fs33.pathExists(dest);
|
|
6915
7758
|
if (exists && !opts.force) {
|
|
6916
7759
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
6917
7760
|
return "skipped-exists";
|
|
6918
7761
|
}
|
|
6919
7762
|
if (opts.dryRun) {
|
|
6920
|
-
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${
|
|
7763
|
+
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path46.basename(dest)} (${itemId})`);
|
|
6921
7764
|
return "scaffolded";
|
|
6922
7765
|
}
|
|
6923
7766
|
if (exists && opts.force) {
|
|
6924
|
-
await
|
|
7767
|
+
await fs33.remove(dest);
|
|
6925
7768
|
}
|
|
6926
|
-
await
|
|
6927
|
-
await
|
|
7769
|
+
await fs33.ensureDir(path46.dirname(dest));
|
|
7770
|
+
await fs33.copy(src, dest, {
|
|
6928
7771
|
overwrite: true,
|
|
6929
7772
|
dereference: false,
|
|
6930
7773
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
6931
7774
|
filter: (s) => {
|
|
6932
7775
|
try {
|
|
6933
|
-
return !
|
|
7776
|
+
return !fs33.lstatSync(s).isSymbolicLink();
|
|
6934
7777
|
} catch {
|
|
6935
7778
|
return false;
|
|
6936
7779
|
}
|
|
6937
7780
|
}
|
|
6938
7781
|
});
|
|
6939
|
-
log(`${exists ? "Overwrote" : "Created"} ${
|
|
7782
|
+
log(`${exists ? "Overwrote" : "Created"} ${path46.basename(dest)} (${itemId})`);
|
|
6940
7783
|
return "scaffolded";
|
|
6941
7784
|
}
|
|
6942
7785
|
|
|
@@ -6981,7 +7824,8 @@ async function runScaffold(ids, options) {
|
|
|
6981
7824
|
|
|
6982
7825
|
// src/commands/scan.ts
|
|
6983
7826
|
async function runScan(options) {
|
|
6984
|
-
const
|
|
7827
|
+
const root = process.cwd();
|
|
7828
|
+
const result = await scanProject(root);
|
|
6985
7829
|
if (options.json) {
|
|
6986
7830
|
log(JSON.stringify(result, null, 2));
|
|
6987
7831
|
return;
|
|
@@ -6989,12 +7833,19 @@ async function runScan(options) {
|
|
|
6989
7833
|
log("Haus scan complete");
|
|
6990
7834
|
log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
|
|
6991
7835
|
log(`Package manager: ${result.packageManager}`);
|
|
7836
|
+
if (result.detectionStatus === "unknown") {
|
|
7837
|
+
const { isLinkedWorktree } = await resolveRoots(root);
|
|
7838
|
+
log(`- WARN: ${describeUnknownDetection(result.unsupportedSignals, isLinkedWorktree)}`);
|
|
7839
|
+
}
|
|
6992
7840
|
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
7841
|
+
if (await hasMultipleSiblingRepos(root)) {
|
|
7842
|
+
log(`- WARN: ${SIBLING_REPO_WARNING}`);
|
|
7843
|
+
}
|
|
6993
7844
|
}
|
|
6994
7845
|
|
|
6995
7846
|
// src/commands/undo.ts
|
|
6996
|
-
import
|
|
6997
|
-
import
|
|
7847
|
+
import path47 from "node:path";
|
|
7848
|
+
import fs34 from "fs-extra";
|
|
6998
7849
|
|
|
6999
7850
|
// src/claude/managed-paths.ts
|
|
7000
7851
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
|
|
@@ -7009,7 +7860,7 @@ function coreManagedAbsolutePaths(root) {
|
|
|
7009
7860
|
async function collectCoreManagedPaths(root) {
|
|
7010
7861
|
const existing = [];
|
|
7011
7862
|
for (const abs of coreManagedAbsolutePaths(root)) {
|
|
7012
|
-
if (await
|
|
7863
|
+
if (await fs34.pathExists(abs)) existing.push(abs);
|
|
7013
7864
|
}
|
|
7014
7865
|
return existing;
|
|
7015
7866
|
}
|
|
@@ -7021,18 +7872,18 @@ async function collectLockTrackedPaths(root) {
|
|
|
7021
7872
|
const relPaths = row.paths ?? [];
|
|
7022
7873
|
const existingRel = [];
|
|
7023
7874
|
for (const rel of relPaths) {
|
|
7024
|
-
if (await
|
|
7875
|
+
if (await fs34.pathExists(path47.resolve(root, rel))) existingRel.push(rel);
|
|
7025
7876
|
}
|
|
7026
7877
|
if (existingRel.length === 0) continue;
|
|
7027
7878
|
if (row.hash === void 0) {
|
|
7028
|
-
removable.push(...existingRel.map((rel) =>
|
|
7879
|
+
removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
|
|
7029
7880
|
continue;
|
|
7030
7881
|
}
|
|
7031
7882
|
const currentHash = await hashInstalledPaths(root, existingRel);
|
|
7032
7883
|
if (currentHash === row.hash) {
|
|
7033
|
-
removable.push(...existingRel.map((rel) =>
|
|
7884
|
+
removable.push(...existingRel.map((rel) => path47.resolve(root, rel)));
|
|
7034
7885
|
} else {
|
|
7035
|
-
preserved.push(...existingRel.map((rel) =>
|
|
7886
|
+
preserved.push(...existingRel.map((rel) => path47.resolve(root, rel)));
|
|
7036
7887
|
log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
|
|
7037
7888
|
}
|
|
7038
7889
|
}
|
|
@@ -7040,51 +7891,51 @@ async function collectLockTrackedPaths(root) {
|
|
|
7040
7891
|
}
|
|
7041
7892
|
async function settingsHasHausContent(root) {
|
|
7042
7893
|
const settingsPath = claudePath(root, "settings.json");
|
|
7043
|
-
if (!await
|
|
7894
|
+
if (!await fs34.pathExists(settingsPath)) return false;
|
|
7044
7895
|
const settings = await readProjectSettings(root);
|
|
7045
7896
|
return settings._haus != null;
|
|
7046
7897
|
}
|
|
7047
7898
|
async function claudeMdHasHausBlock(root) {
|
|
7048
|
-
const filePath =
|
|
7049
|
-
if (!await
|
|
7050
|
-
const text = await
|
|
7899
|
+
const filePath = path47.join(root, "CLAUDE.md");
|
|
7900
|
+
if (!await fs34.pathExists(filePath)) return false;
|
|
7901
|
+
const text = await fs34.readFile(filePath, "utf8");
|
|
7051
7902
|
return text.includes(BLOCK_BEGIN);
|
|
7052
7903
|
}
|
|
7053
7904
|
async function stripProjectSettings(root) {
|
|
7054
7905
|
const settingsPath = claudePath(root, "settings.json");
|
|
7055
|
-
if (!await
|
|
7906
|
+
if (!await fs34.pathExists(settingsPath)) return false;
|
|
7056
7907
|
let settings = await readProjectSettings(root);
|
|
7057
7908
|
settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
7058
7909
|
const hasContent = Object.keys(settings).length > 0;
|
|
7059
7910
|
if (hasContent) {
|
|
7060
7911
|
await writeProjectSettings(root, settings);
|
|
7061
|
-
log(`Stripped haus rules from ${
|
|
7912
|
+
log(`Stripped haus rules from ${path47.relative(root, settingsPath)} (user settings preserved).`);
|
|
7062
7913
|
return true;
|
|
7063
7914
|
}
|
|
7064
|
-
await
|
|
7065
|
-
log(`Removed ${
|
|
7915
|
+
await fs34.remove(settingsPath);
|
|
7916
|
+
log(`Removed ${path47.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
7066
7917
|
return true;
|
|
7067
7918
|
}
|
|
7068
7919
|
async function stripRootClaudeMd(root) {
|
|
7069
|
-
const filePath =
|
|
7070
|
-
if (!await
|
|
7071
|
-
const prev = await
|
|
7920
|
+
const filePath = path47.join(root, "CLAUDE.md");
|
|
7921
|
+
if (!await fs34.pathExists(filePath)) return false;
|
|
7922
|
+
const prev = await fs34.readFile(filePath, "utf8");
|
|
7072
7923
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
7073
7924
|
const next = stripHausBlock(prev);
|
|
7074
7925
|
if (next.length === 0) {
|
|
7075
|
-
await
|
|
7926
|
+
await fs34.remove(filePath);
|
|
7076
7927
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
7077
7928
|
} else {
|
|
7078
|
-
await
|
|
7929
|
+
await fs34.writeFile(filePath, next, "utf8");
|
|
7079
7930
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
7080
7931
|
}
|
|
7081
7932
|
return true;
|
|
7082
7933
|
}
|
|
7083
7934
|
async function pruneDirIfEmpty(dir) {
|
|
7084
|
-
if (!await
|
|
7935
|
+
if (!await fs34.pathExists(dir)) return;
|
|
7085
7936
|
try {
|
|
7086
|
-
const entries = await
|
|
7087
|
-
if (entries.length === 0) await
|
|
7937
|
+
const entries = await fs34.readdir(dir);
|
|
7938
|
+
if (entries.length === 0) await fs34.remove(dir);
|
|
7088
7939
|
} catch {
|
|
7089
7940
|
}
|
|
7090
7941
|
}
|
|
@@ -7093,13 +7944,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
|
7093
7944
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
7094
7945
|
const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
|
|
7095
7946
|
for (const abs of managedAbsPaths) {
|
|
7096
|
-
if (!await
|
|
7097
|
-
const rel =
|
|
7098
|
-
const backupPath =
|
|
7099
|
-
await
|
|
7100
|
-
await
|
|
7947
|
+
if (!await fs34.pathExists(abs)) continue;
|
|
7948
|
+
const rel = path47.relative(root, abs);
|
|
7949
|
+
const backupPath = path47.join(backupRoot, rel);
|
|
7950
|
+
await fs34.ensureDir(path47.dirname(backupPath));
|
|
7951
|
+
await fs34.copy(abs, backupPath);
|
|
7101
7952
|
}
|
|
7102
|
-
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${
|
|
7953
|
+
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path47.relative(root, backupRoot)}.`);
|
|
7103
7954
|
}
|
|
7104
7955
|
async function runUndo(options) {
|
|
7105
7956
|
const root = options.root ?? process.cwd();
|
|
@@ -7112,7 +7963,7 @@ async function runUndo(options) {
|
|
|
7112
7963
|
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
7113
7964
|
return;
|
|
7114
7965
|
}
|
|
7115
|
-
const relTargets = managed.map((p) =>
|
|
7966
|
+
const relTargets = managed.map((p) => path47.relative(root, p));
|
|
7116
7967
|
const summaryParts = [...relTargets];
|
|
7117
7968
|
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
7118
7969
|
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
@@ -7134,9 +7985,9 @@ User-owned .claude/ files will be preserved.`
|
|
|
7134
7985
|
}
|
|
7135
7986
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
7136
7987
|
for (const abs of managed) {
|
|
7137
|
-
if (!await
|
|
7138
|
-
await
|
|
7139
|
-
log(`Removed ${
|
|
7988
|
+
if (!await fs34.pathExists(abs)) continue;
|
|
7989
|
+
await fs34.remove(abs);
|
|
7990
|
+
log(`Removed ${path47.relative(root, abs)}`);
|
|
7140
7991
|
}
|
|
7141
7992
|
if (stripSettings) await stripProjectSettings(root);
|
|
7142
7993
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
@@ -7147,8 +7998,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
7147
7998
|
|
|
7148
7999
|
// src/install/uninstall.ts
|
|
7149
8000
|
import crypto3 from "node:crypto";
|
|
7150
|
-
import
|
|
7151
|
-
import
|
|
8001
|
+
import path48 from "node:path";
|
|
8002
|
+
import fs35 from "fs-extra";
|
|
7152
8003
|
async function runUninstall(options = {}) {
|
|
7153
8004
|
const { force = false } = options;
|
|
7154
8005
|
const manifest = await readManifest();
|
|
@@ -7158,7 +8009,7 @@ async function runUninstall(options = {}) {
|
|
|
7158
8009
|
return result;
|
|
7159
8010
|
}
|
|
7160
8011
|
for (const entry of manifest.files) {
|
|
7161
|
-
const exists =
|
|
8012
|
+
const exists = fs35.pathExistsSync(entry.destPath);
|
|
7162
8013
|
if (!exists) continue;
|
|
7163
8014
|
const content2 = await readText(entry.destPath);
|
|
7164
8015
|
if (content2 === void 0) continue;
|
|
@@ -7176,23 +8027,23 @@ async function runUninstall(options = {}) {
|
|
|
7176
8027
|
result.skipped.push(entry.destPath);
|
|
7177
8028
|
continue;
|
|
7178
8029
|
}
|
|
7179
|
-
await
|
|
7180
|
-
await pruneEmptyDir(
|
|
8030
|
+
await fs35.remove(entry.destPath);
|
|
8031
|
+
await pruneEmptyDir(path48.dirname(entry.destPath));
|
|
7181
8032
|
result.deleted.push(entry.destPath);
|
|
7182
8033
|
}
|
|
7183
8034
|
const settings = await readSettings();
|
|
7184
8035
|
const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
7185
8036
|
await writeSettings(stripped);
|
|
7186
8037
|
result.hooksStripped = true;
|
|
7187
|
-
const hausDir =
|
|
8038
|
+
const hausDir = path48.join(globalClaudeDir(), "haus");
|
|
7188
8039
|
const manifestPath2 = hausManifestPath();
|
|
7189
|
-
if (
|
|
7190
|
-
await
|
|
8040
|
+
if (fs35.pathExistsSync(manifestPath2)) {
|
|
8041
|
+
await fs35.remove(manifestPath2);
|
|
7191
8042
|
}
|
|
7192
|
-
if (
|
|
8043
|
+
if (fs35.pathExistsSync(hausDir)) {
|
|
7193
8044
|
try {
|
|
7194
|
-
const remaining = await
|
|
7195
|
-
if (remaining.length === 0) await
|
|
8045
|
+
const remaining = await fs35.readdir(hausDir);
|
|
8046
|
+
if (remaining.length === 0) await fs35.remove(hausDir);
|
|
7196
8047
|
} catch {
|
|
7197
8048
|
}
|
|
7198
8049
|
}
|
|
@@ -7225,12 +8076,13 @@ async function runUninstallCommand(options) {
|
|
|
7225
8076
|
}
|
|
7226
8077
|
|
|
7227
8078
|
// src/commands/validate-catalog.ts
|
|
7228
|
-
import
|
|
8079
|
+
import path50 from "node:path";
|
|
7229
8080
|
|
|
7230
8081
|
// src/catalog/validate-core.ts
|
|
7231
|
-
import
|
|
7232
|
-
import
|
|
8082
|
+
import fs36 from "node:fs";
|
|
8083
|
+
import path49 from "node:path";
|
|
7233
8084
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
8085
|
+
var ITEM_ID_PATTERN = /^haus\./;
|
|
7234
8086
|
function auditForbiddenStacks(items) {
|
|
7235
8087
|
const failures = [];
|
|
7236
8088
|
for (const item of items) {
|
|
@@ -7316,6 +8168,9 @@ function auditManifestStructure(manifestVersion, items) {
|
|
|
7316
8168
|
failures.push(`item[${i}]: missing id`);
|
|
7317
8169
|
continue;
|
|
7318
8170
|
}
|
|
8171
|
+
if (!ITEM_ID_PATTERN.test(item.id)) {
|
|
8172
|
+
failures.push(`${item.id}: id must start with "haus."`);
|
|
8173
|
+
}
|
|
7319
8174
|
const provenanceError = validateCuratedProvenance(item);
|
|
7320
8175
|
if (provenanceError) failures.push(provenanceError);
|
|
7321
8176
|
const referencesError = validateReferences(item.id, item.references);
|
|
@@ -7367,14 +8222,14 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
7367
8222
|
}
|
|
7368
8223
|
function readForAudit(absPath, label, failures) {
|
|
7369
8224
|
try {
|
|
7370
|
-
return
|
|
8225
|
+
return fs36.readFileSync(absPath, "utf8");
|
|
7371
8226
|
} catch (err) {
|
|
7372
8227
|
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
7373
8228
|
return null;
|
|
7374
8229
|
}
|
|
7375
8230
|
}
|
|
7376
8231
|
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
7377
|
-
const rel =
|
|
8232
|
+
const rel = path49.relative(manifestDir, absPath);
|
|
7378
8233
|
const failures = [];
|
|
7379
8234
|
const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
|
|
7380
8235
|
if (text === null) return failures;
|
|
@@ -7396,47 +8251,47 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
7396
8251
|
const failures = [];
|
|
7397
8252
|
for (const item of items) {
|
|
7398
8253
|
if (!item.path || !isSafeCatalogPath(item.path)) continue;
|
|
7399
|
-
const absPath =
|
|
8254
|
+
const absPath = path49.join(manifestDir, item.path);
|
|
7400
8255
|
if (item.type === "skill") {
|
|
7401
|
-
const skillMd =
|
|
7402
|
-
if (!
|
|
7403
|
-
failures.push(`${item.id}: missing ${
|
|
8256
|
+
const skillMd = path49.join(absPath, "SKILL.md");
|
|
8257
|
+
if (!fs36.existsSync(skillMd)) {
|
|
8258
|
+
failures.push(`${item.id}: missing ${path49.relative(manifestDir, skillMd)}`);
|
|
7404
8259
|
continue;
|
|
7405
8260
|
}
|
|
7406
8261
|
const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
|
|
7407
8262
|
if (text === null) continue;
|
|
7408
8263
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
|
|
7409
8264
|
failures.push(
|
|
7410
|
-
...auditForbiddenTagsInText(text, `${item.id}: ${
|
|
8265
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path49.relative(manifestDir, skillMd)}`)
|
|
7411
8266
|
);
|
|
7412
8267
|
} else if (item.type === "agent") {
|
|
7413
|
-
if (!
|
|
8268
|
+
if (!fs36.existsSync(absPath)) {
|
|
7414
8269
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
7415
8270
|
continue;
|
|
7416
8271
|
}
|
|
7417
|
-
const rel =
|
|
8272
|
+
const rel = path49.relative(manifestDir, absPath);
|
|
7418
8273
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
7419
8274
|
if (text === null) continue;
|
|
7420
8275
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
7421
8276
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
7422
8277
|
} else if (item.type === "template") {
|
|
7423
|
-
if (!
|
|
8278
|
+
if (!fs36.existsSync(absPath)) {
|
|
7424
8279
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
7425
8280
|
continue;
|
|
7426
8281
|
}
|
|
7427
8282
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
7428
8283
|
} else if (item.type === "command") {
|
|
7429
|
-
if (!
|
|
8284
|
+
if (!fs36.existsSync(absPath)) {
|
|
7430
8285
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
7431
8286
|
continue;
|
|
7432
8287
|
}
|
|
7433
|
-
const rel =
|
|
8288
|
+
const rel = path49.relative(manifestDir, absPath);
|
|
7434
8289
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
7435
8290
|
if (text === null) continue;
|
|
7436
8291
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
7437
8292
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
7438
8293
|
} else if (item.type === "config") {
|
|
7439
|
-
if (!
|
|
8294
|
+
if (!fs36.existsSync(absPath)) {
|
|
7440
8295
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
7441
8296
|
}
|
|
7442
8297
|
}
|
|
@@ -7453,7 +8308,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
|
|
|
7453
8308
|
function walkAllFiles(dir, fn, failures) {
|
|
7454
8309
|
let entries;
|
|
7455
8310
|
try {
|
|
7456
|
-
entries =
|
|
8311
|
+
entries = fs36.readdirSync(dir, { withFileTypes: true });
|
|
7457
8312
|
} catch (err) {
|
|
7458
8313
|
failures.push(
|
|
7459
8314
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -7462,7 +8317,7 @@ function walkAllFiles(dir, fn, failures) {
|
|
|
7462
8317
|
}
|
|
7463
8318
|
for (const entry of entries) {
|
|
7464
8319
|
if (entry.name === ".DS_Store") continue;
|
|
7465
|
-
const full =
|
|
8320
|
+
const full = path49.join(dir, entry.name);
|
|
7466
8321
|
if (entry.isDirectory()) walkAllFiles(full, fn, failures);
|
|
7467
8322
|
else fn(full);
|
|
7468
8323
|
}
|
|
@@ -7480,12 +8335,12 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
7480
8335
|
const failures = [];
|
|
7481
8336
|
const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
|
|
7482
8337
|
for (const dir of CONTENT_ROOTS) {
|
|
7483
|
-
const abs =
|
|
7484
|
-
if (!
|
|
8338
|
+
const abs = path49.join(manifestDir, dir);
|
|
8339
|
+
if (!fs36.existsSync(abs)) continue;
|
|
7485
8340
|
walkAllFiles(
|
|
7486
8341
|
abs,
|
|
7487
8342
|
(file) => {
|
|
7488
|
-
const relPath =
|
|
8343
|
+
const relPath = path49.relative(manifestDir, file).replace(/\\/g, "/");
|
|
7489
8344
|
if (!isPathClaimed(relPath, claimedPaths)) {
|
|
7490
8345
|
failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
|
|
7491
8346
|
}
|
|
@@ -7498,7 +8353,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
7498
8353
|
function walkMd(dir, fn, failures) {
|
|
7499
8354
|
let entries;
|
|
7500
8355
|
try {
|
|
7501
|
-
entries =
|
|
8356
|
+
entries = fs36.readdirSync(dir, { withFileTypes: true });
|
|
7502
8357
|
} catch (err) {
|
|
7503
8358
|
failures.push(
|
|
7504
8359
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -7506,7 +8361,7 @@ function walkMd(dir, fn, failures) {
|
|
|
7506
8361
|
return;
|
|
7507
8362
|
}
|
|
7508
8363
|
for (const entry of entries) {
|
|
7509
|
-
const full =
|
|
8364
|
+
const full = path49.join(dir, entry.name);
|
|
7510
8365
|
if (entry.isDirectory()) walkMd(full, fn, failures);
|
|
7511
8366
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
7512
8367
|
}
|
|
@@ -7515,13 +8370,13 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
7515
8370
|
const failures = [];
|
|
7516
8371
|
const pathSourceMap = buildItemPathSourceMap(items);
|
|
7517
8372
|
for (const dir of ["skills", "agents", "templates", "commands"]) {
|
|
7518
|
-
const abs =
|
|
7519
|
-
if (!
|
|
8373
|
+
const abs = path49.join(manifestDir, dir);
|
|
8374
|
+
if (!fs36.existsSync(abs)) continue;
|
|
7520
8375
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
7521
8376
|
walkMd(
|
|
7522
8377
|
abs,
|
|
7523
8378
|
(file) => {
|
|
7524
|
-
const rel =
|
|
8379
|
+
const rel = path49.relative(manifestDir, file);
|
|
7525
8380
|
const text = readForAudit(file, rel, failures);
|
|
7526
8381
|
if (text === null) return;
|
|
7527
8382
|
const norm = rel.replace(/\\/g, "/");
|
|
@@ -7566,8 +8421,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
7566
8421
|
process.exitCode = 1;
|
|
7567
8422
|
return;
|
|
7568
8423
|
}
|
|
7569
|
-
const abs =
|
|
7570
|
-
const manifestDir =
|
|
8424
|
+
const abs = path50.resolve(process.cwd(), manifestPath2);
|
|
8425
|
+
const manifestDir = path50.dirname(abs);
|
|
7571
8426
|
const data = await readJson(abs);
|
|
7572
8427
|
if (!data?.items) {
|
|
7573
8428
|
error(`Could not read catalog manifest at ${abs}`);
|
|
@@ -7583,231 +8438,1097 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
7583
8438
|
log(`Catalog valid. ${result.items.length} items checked.`);
|
|
7584
8439
|
}
|
|
7585
8440
|
|
|
7586
|
-
// src/
|
|
7587
|
-
import
|
|
7588
|
-
import
|
|
8441
|
+
// src/workspace/worktree/add.ts
|
|
8442
|
+
import path57 from "node:path";
|
|
8443
|
+
import fs40 from "fs-extra";
|
|
7589
8444
|
|
|
7590
|
-
// src/
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
for (const repo of summaries) {
|
|
7601
|
-
for (const dep2 of repo.deps) {
|
|
7602
|
-
ownership[dep2] ??= [];
|
|
7603
|
-
ownership[dep2].push(repo.name);
|
|
7604
|
-
}
|
|
8445
|
+
// src/workspace/worktree/git-worktree.ts
|
|
8446
|
+
init_exec();
|
|
8447
|
+
async function branchExists(cwd, branch) {
|
|
8448
|
+
const result = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd });
|
|
8449
|
+
return result.exitCode === 0;
|
|
8450
|
+
}
|
|
8451
|
+
async function resolveDefaultBranch(cwd) {
|
|
8452
|
+
const symbolic = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
|
|
8453
|
+
if (symbolic.exitCode === 0 && symbolic.stdout.trim()) {
|
|
8454
|
+
return { ref: symbolic.stdout.trim(), source: "origin-head" };
|
|
7605
8455
|
}
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
8456
|
+
if (await branchExists(cwd, "main")) return { ref: "main", source: "local-main" };
|
|
8457
|
+
if (await branchExists(cwd, "master")) return { ref: "master", source: "local-master" };
|
|
8458
|
+
return void 0;
|
|
8459
|
+
}
|
|
8460
|
+
var CHECKED_OUT_ELSEWHERE_PATTERNS = [/already checked out at/i, /already used by worktree/i];
|
|
8461
|
+
function isBranchCheckedOutElsewhereError(stderr) {
|
|
8462
|
+
return CHECKED_OUT_ELSEWHERE_PATTERNS.some((re) => re.test(stderr));
|
|
8463
|
+
}
|
|
8464
|
+
async function addWorktree(cwd, worktreePath2, branch, opts = {}) {
|
|
8465
|
+
const exists = await branchExists(cwd, branch);
|
|
8466
|
+
if (exists) {
|
|
8467
|
+
const result2 = await runGit(["worktree", "add", worktreePath2, branch], { cwd });
|
|
8468
|
+
return {
|
|
8469
|
+
ok: result2.exitCode === 0,
|
|
8470
|
+
branchAction: "checkout",
|
|
8471
|
+
stderr: result2.exitCode === 0 ? void 0 : result2.stderr,
|
|
8472
|
+
checkedOutElsewhere: result2.exitCode !== 0 && isBranchCheckedOutElsewhereError(result2.stderr)
|
|
8473
|
+
};
|
|
8474
|
+
}
|
|
8475
|
+
let startPoint;
|
|
8476
|
+
if (opts.preferDefaultBranchFrom) {
|
|
8477
|
+
const defaultBranch = await resolveDefaultBranch(cwd);
|
|
8478
|
+
startPoint = defaultBranch?.ref;
|
|
8479
|
+
}
|
|
8480
|
+
const args = startPoint ? ["worktree", "add", worktreePath2, "-b", branch, startPoint] : ["worktree", "add", worktreePath2, "-b", branch];
|
|
8481
|
+
const result = await runGit(args, { cwd });
|
|
8482
|
+
return {
|
|
8483
|
+
ok: result.exitCode === 0,
|
|
8484
|
+
branchAction: "create",
|
|
8485
|
+
startPoint,
|
|
8486
|
+
stderr: result.exitCode === 0 ? void 0 : result.stderr,
|
|
8487
|
+
checkedOutElsewhere: result.exitCode !== 0 && isBranchCheckedOutElsewhereError(result.stderr)
|
|
8488
|
+
};
|
|
8489
|
+
}
|
|
8490
|
+
async function removeWorktree(ownerRepo, worktreePath2, force = true) {
|
|
8491
|
+
const args = ["worktree", "remove", worktreePath2];
|
|
8492
|
+
if (force) args.push("--force");
|
|
8493
|
+
const result = await runGit(args, { cwd: ownerRepo });
|
|
8494
|
+
return { ok: result.exitCode === 0, stderr: result.exitCode === 0 ? void 0 : result.stderr };
|
|
8495
|
+
}
|
|
8496
|
+
async function pruneWorktrees(ownerRepo) {
|
|
8497
|
+
await runGit(["worktree", "prune"], { cwd: ownerRepo });
|
|
8498
|
+
}
|
|
8499
|
+
async function listWorktrees(ownerRepo) {
|
|
8500
|
+
const result = await runGit(["worktree", "list", "--porcelain"], { cwd: ownerRepo });
|
|
8501
|
+
if (result.exitCode !== 0) return [];
|
|
8502
|
+
const entries = [];
|
|
8503
|
+
let current = {};
|
|
8504
|
+
for (const line2 of result.stdout.split("\n")) {
|
|
8505
|
+
if (line2.startsWith("worktree ")) {
|
|
8506
|
+
if (current.path) entries.push(current);
|
|
8507
|
+
current = { path: line2.slice("worktree ".length).trim() };
|
|
8508
|
+
} else if (line2.startsWith("branch ")) {
|
|
8509
|
+
current.branch = line2.slice("branch ".length).replace("refs/heads/", "").trim();
|
|
8510
|
+
} else if (line2.startsWith("HEAD ")) {
|
|
8511
|
+
current.head = line2.slice("HEAD ".length).trim();
|
|
8512
|
+
} else if (line2 === "bare") {
|
|
8513
|
+
current.bare = true;
|
|
8514
|
+
} else if (line2 === "detached") {
|
|
8515
|
+
current.detached = true;
|
|
8516
|
+
}
|
|
8517
|
+
}
|
|
8518
|
+
if (current.path) entries.push(current);
|
|
8519
|
+
return entries;
|
|
8520
|
+
}
|
|
8521
|
+
async function currentBranch(cwd) {
|
|
8522
|
+
const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
|
|
8523
|
+
return result.exitCode === 0 ? result.stdout.trim() : void 0;
|
|
8524
|
+
}
|
|
8525
|
+
async function hasUncommittedChanges(cwd, ignorePaths = []) {
|
|
8526
|
+
const args = ["status", "--porcelain"];
|
|
8527
|
+
if (ignorePaths.length > 0) {
|
|
8528
|
+
args.push("--", ".", ...ignorePaths.map((p) => `:!${p}`));
|
|
8529
|
+
}
|
|
8530
|
+
const result = await runGit(args, { cwd });
|
|
8531
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
8532
|
+
}
|
|
8533
|
+
async function hasUnpushedWork(cwd) {
|
|
8534
|
+
const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
|
|
8535
|
+
cwd
|
|
7637
8536
|
});
|
|
7638
|
-
|
|
8537
|
+
if (upstream.exitCode === 0 && upstream.stdout.trim()) {
|
|
8538
|
+
const upstreamRef = upstream.stdout.trim();
|
|
8539
|
+
const ahead = await runGit(["rev-list", "--count", `${upstreamRef}..HEAD`], { cwd });
|
|
8540
|
+
const count = Number.parseInt(ahead.stdout.trim(), 10);
|
|
8541
|
+
if (Number.isFinite(count) && count > 0) {
|
|
8542
|
+
return { unpushed: true, reason: `${count} commit(s) ahead of ${upstreamRef}` };
|
|
8543
|
+
}
|
|
8544
|
+
return { unpushed: false };
|
|
8545
|
+
}
|
|
8546
|
+
const defaultBranch = await resolveDefaultBranch(cwd);
|
|
8547
|
+
if (!defaultBranch) return { unpushed: false };
|
|
8548
|
+
const [head, defaultSha] = await Promise.all([
|
|
8549
|
+
runGit(["rev-parse", "HEAD"], { cwd }),
|
|
8550
|
+
runGit(["rev-parse", defaultBranch.ref], { cwd })
|
|
8551
|
+
]);
|
|
8552
|
+
if (head.exitCode === 0 && defaultSha.exitCode === 0 && head.stdout.trim() === defaultSha.stdout.trim()) {
|
|
8553
|
+
return { unpushed: false };
|
|
8554
|
+
}
|
|
8555
|
+
return {
|
|
8556
|
+
unpushed: true,
|
|
8557
|
+
reason: `no upstream tracking branch configured, and HEAD differs from ${defaultBranch.ref}`
|
|
8558
|
+
};
|
|
7639
8559
|
}
|
|
7640
8560
|
|
|
7641
|
-
// src/
|
|
7642
|
-
import
|
|
7643
|
-
import
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
8561
|
+
// src/workspace/worktree/hydrate.ts
|
|
8562
|
+
import path53 from "node:path";
|
|
8563
|
+
import fs38 from "fs-extra";
|
|
8564
|
+
|
|
8565
|
+
// src/workspace/worktree/cow-copy.ts
|
|
8566
|
+
init_exec();
|
|
8567
|
+
import path51 from "node:path";
|
|
8568
|
+
import fs37 from "fs-extra";
|
|
8569
|
+
async function getMacFilesystemType(dir) {
|
|
8570
|
+
const df = await runCommand("df", ["-P", dir]);
|
|
8571
|
+
if (df.exitCode !== 0) return void 0;
|
|
8572
|
+
const lines = df.stdout.trim().split("\n");
|
|
8573
|
+
const dataLine = lines[lines.length - 1];
|
|
8574
|
+
const device = dataLine?.split(/\s+/)[0];
|
|
8575
|
+
if (!device) return void 0;
|
|
8576
|
+
const mount = await runCommand("mount");
|
|
8577
|
+
if (mount.exitCode !== 0) return void 0;
|
|
8578
|
+
const line2 = mount.stdout.split("\n").find((l) => l.startsWith(`${device} `));
|
|
8579
|
+
const match = line2?.match(/\(([^,)]+)/);
|
|
8580
|
+
return match?.[1]?.trim();
|
|
8581
|
+
}
|
|
8582
|
+
async function getLinuxFilesystemType(dir) {
|
|
8583
|
+
const result = await runCommand("stat", ["-f", "-c", "%T", dir]);
|
|
8584
|
+
if (result.exitCode !== 0) return void 0;
|
|
8585
|
+
return result.stdout.trim();
|
|
8586
|
+
}
|
|
8587
|
+
async function detectCowStrategy(dir) {
|
|
8588
|
+
if (process.platform === "darwin") {
|
|
8589
|
+
const fsType = await getMacFilesystemType(dir);
|
|
8590
|
+
return fsType === "apfs" ? "darwin-clonefile" : "unsupported";
|
|
8591
|
+
}
|
|
8592
|
+
if (process.platform === "linux") {
|
|
8593
|
+
const fsType = await getLinuxFilesystemType(dir);
|
|
8594
|
+
return fsType === "btrfs" || fsType === "xfs" ? "linux-reflink" : "unsupported";
|
|
8595
|
+
}
|
|
8596
|
+
return "unknown-platform";
|
|
8597
|
+
}
|
|
8598
|
+
async function cowCopyDir(src, dest) {
|
|
8599
|
+
const srcExists = await fs37.pathExists(src);
|
|
8600
|
+
if (!srcExists)
|
|
8601
|
+
return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
|
|
8602
|
+
const strategy = await detectCowStrategy(path51.dirname(dest));
|
|
8603
|
+
if (strategy === "unsupported" || strategy === "unknown-platform") {
|
|
8604
|
+
return { strategy, attempted: false, ok: false };
|
|
8605
|
+
}
|
|
8606
|
+
await fs37.ensureDir(path51.dirname(dest));
|
|
8607
|
+
const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
|
|
8608
|
+
const result = await runCommand("cp", args);
|
|
8609
|
+
return {
|
|
8610
|
+
strategy,
|
|
8611
|
+
attempted: true,
|
|
8612
|
+
ok: result.exitCode === 0,
|
|
8613
|
+
error: result.exitCode === 0 ? void 0 : result.stderr
|
|
8614
|
+
};
|
|
8615
|
+
}
|
|
8616
|
+
|
|
8617
|
+
// src/workspace/worktree/install.ts
|
|
8618
|
+
init_exec();
|
|
8619
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
8620
|
+
import path52 from "node:path";
|
|
8621
|
+
import fg5 from "fast-glob";
|
|
8622
|
+
function detectInstallPlan(signals) {
|
|
8623
|
+
if (signals.hasYarnLock) {
|
|
8624
|
+
return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
|
|
8625
|
+
}
|
|
8626
|
+
if (signals.hasPnpmLock) {
|
|
8627
|
+
return { manager: "pnpm", command: "pnpm", args: ["install", "--frozen-lockfile"] };
|
|
8628
|
+
}
|
|
8629
|
+
if (signals.hasPackageLock) {
|
|
8630
|
+
return { manager: "npm", command: "npm", args: ["install"] };
|
|
8631
|
+
}
|
|
8632
|
+
if (signals.hasComposerLock) {
|
|
8633
|
+
return { manager: "composer", command: "composer", args: ["install"] };
|
|
8634
|
+
}
|
|
8635
|
+
if (signals.hasDotnetProject) {
|
|
8636
|
+
return { manager: "dotnet", command: "dotnet", args: ["restore"] };
|
|
8637
|
+
}
|
|
8638
|
+
return { manager: "none" };
|
|
8639
|
+
}
|
|
8640
|
+
async function computeLockfileSignals(dir) {
|
|
8641
|
+
const hasYarnLock = existsSync3(path52.join(dir, "yarn.lock"));
|
|
8642
|
+
const hasPnpmLock = existsSync3(path52.join(dir, "pnpm-lock.yaml"));
|
|
8643
|
+
const hasPackageLock = existsSync3(path52.join(dir, "package-lock.json"));
|
|
8644
|
+
const hasComposerLock = existsSync3(path52.join(dir, "composer.lock"));
|
|
8645
|
+
const dotnetFiles = await fg5(["**/*.csproj", "**/*.sln"], {
|
|
8646
|
+
cwd: dir,
|
|
8647
|
+
onlyFiles: true,
|
|
8648
|
+
suppressErrors: true,
|
|
8649
|
+
// Consistent with this codebase's no-symlink-follow posture elsewhere
|
|
8650
|
+
// (fs.ts's findNestedRepoDirs, discover.ts's discoverRepos): a symlink
|
|
8651
|
+
// shouldn't be able to route this search outside the intended tree or
|
|
8652
|
+
// reintroduce a symlink-cycle/perf concern.
|
|
8653
|
+
followSymbolicLinks: false,
|
|
8654
|
+
ignore: ["**/node_modules/**", "**/bin/**", "**/obj/**"]
|
|
8655
|
+
});
|
|
8656
|
+
const hasDotnetProject = dotnetFiles.length > 0;
|
|
8657
|
+
let hasPackageManagerField = false;
|
|
8658
|
+
const pkgPath = path52.join(dir, "package.json");
|
|
8659
|
+
if (existsSync3(pkgPath)) {
|
|
8660
|
+
try {
|
|
8661
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
8662
|
+
hasPackageManagerField = typeof pkg.packageManager === "string" && pkg.packageManager.length > 0;
|
|
8663
|
+
} catch {
|
|
8664
|
+
}
|
|
7652
8665
|
}
|
|
7653
|
-
if (!parsed || typeof parsed !== "object") return void 0;
|
|
7654
|
-
const obj = parsed;
|
|
7655
|
-
const repos = Array.isArray(obj.repos) ? obj.repos.filter(
|
|
7656
|
-
(r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
|
|
7657
|
-
) : [];
|
|
7658
8666
|
return {
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
8667
|
+
hasYarnLock,
|
|
8668
|
+
hasPackageManagerField,
|
|
8669
|
+
hasPnpmLock,
|
|
8670
|
+
hasPackageLock,
|
|
8671
|
+
hasComposerLock,
|
|
8672
|
+
hasDotnetProject
|
|
7662
8673
|
};
|
|
7663
8674
|
}
|
|
7664
|
-
async function
|
|
7665
|
-
|
|
8675
|
+
async function runInstallPlan(dir, plan) {
|
|
8676
|
+
if (plan.manager === "none") {
|
|
8677
|
+
return { plan, ran: false, ok: true, skippedReason: "no recognized lockfile" };
|
|
8678
|
+
}
|
|
8679
|
+
const available = await commandExists(plan.command);
|
|
8680
|
+
if (!available) {
|
|
8681
|
+
return { plan, ran: false, ok: true, skippedReason: `${plan.command} not found on PATH` };
|
|
8682
|
+
}
|
|
8683
|
+
const result = await runCommand(plan.command, plan.args, { cwd: dir });
|
|
8684
|
+
return {
|
|
8685
|
+
plan,
|
|
8686
|
+
ran: true,
|
|
8687
|
+
ok: result.exitCode === 0,
|
|
8688
|
+
exitCode: result.exitCode,
|
|
8689
|
+
stderr: result.exitCode === 0 ? void 0 : result.stderr
|
|
8690
|
+
};
|
|
7666
8691
|
}
|
|
7667
8692
|
|
|
7668
|
-
// src/
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
8693
|
+
// src/workspace/worktree/hydrate.ts
|
|
8694
|
+
var HYDRATION_TARGETS = ["node_modules", "vendor", "obj", "bin"];
|
|
8695
|
+
async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
8696
|
+
const start = Date.now();
|
|
8697
|
+
const targets = [];
|
|
8698
|
+
for (const target of HYDRATION_TARGETS) {
|
|
8699
|
+
const src = path53.join(mainMemberPath, target);
|
|
8700
|
+
const dest = path53.join(worktreeMemberPath, target);
|
|
8701
|
+
if (!await fs38.pathExists(src)) continue;
|
|
8702
|
+
const destExists = await fs38.pathExists(dest);
|
|
8703
|
+
if (!opts.force && destExists) continue;
|
|
8704
|
+
if (opts.force && destExists) {
|
|
8705
|
+
await fs38.remove(dest);
|
|
8706
|
+
}
|
|
8707
|
+
const copy = await cowCopyDir(src, dest);
|
|
8708
|
+
targets.push({ target, copy });
|
|
8709
|
+
}
|
|
8710
|
+
const signals = await computeLockfileSignals(worktreeMemberPath);
|
|
8711
|
+
const plan = detectInstallPlan(signals);
|
|
8712
|
+
const install = await runInstallPlan(worktreeMemberPath, plan);
|
|
8713
|
+
return { targets, install, durationMs: Date.now() - start };
|
|
8714
|
+
}
|
|
8715
|
+
|
|
8716
|
+
// src/workspace/worktree/local-files.ts
|
|
8717
|
+
init_exec();
|
|
8718
|
+
import path54 from "node:path";
|
|
8719
|
+
import fs39 from "fs-extra";
|
|
8720
|
+
var MACHINE_LOCAL_FILES = [
|
|
8721
|
+
"docker-compose.local.yml",
|
|
8722
|
+
path54.join(".claude", "settings.local.json")
|
|
7680
8723
|
];
|
|
7681
|
-
function
|
|
7682
|
-
|
|
7683
|
-
|
|
8724
|
+
async function isTracked(repoRoot, relFile) {
|
|
8725
|
+
const gitPath = relFile.replace(/\\/g, "/");
|
|
8726
|
+
const result = await runGit(["ls-files", "--error-unmatch", gitPath], { cwd: repoRoot });
|
|
8727
|
+
return result.exitCode === 0;
|
|
8728
|
+
}
|
|
8729
|
+
async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
|
|
8730
|
+
const copied = [];
|
|
8731
|
+
for (const rel of MACHINE_LOCAL_FILES) {
|
|
8732
|
+
const src = path54.join(mainMemberPath, rel);
|
|
8733
|
+
if (!await fs39.pathExists(src)) continue;
|
|
8734
|
+
if (await isTracked(mainMemberPath, rel)) continue;
|
|
8735
|
+
const dest = path54.join(worktreeMemberPath, rel);
|
|
8736
|
+
await fs39.ensureDir(path54.dirname(dest));
|
|
8737
|
+
await fs39.copy(src, dest, { overwrite: true });
|
|
8738
|
+
copied.push(rel);
|
|
8739
|
+
}
|
|
8740
|
+
return copied;
|
|
8741
|
+
}
|
|
8742
|
+
|
|
8743
|
+
// src/workspace/worktree/root.ts
|
|
8744
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
8745
|
+
import path55 from "node:path";
|
|
8746
|
+
async function resolveWorkspaceForWorktree(start) {
|
|
8747
|
+
const rootInfo = await resolveRoots(start);
|
|
8748
|
+
if (!rootInfo.isGitRepo) {
|
|
8749
|
+
return { ok: false, reason: "Not inside a git repository." };
|
|
8750
|
+
}
|
|
8751
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
8752
|
+
const hasYaml = existsSync4(path55.join(workspaceRoot, WORKSPACE_FILE));
|
|
8753
|
+
const hasManifest = existsSync4(path55.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
8754
|
+
if (!hasYaml && !hasManifest) {
|
|
8755
|
+
return {
|
|
8756
|
+
ok: false,
|
|
8757
|
+
reason: `No workspace found at ${workspaceRoot} \u2014 expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
|
|
8758
|
+
};
|
|
8759
|
+
}
|
|
8760
|
+
return { ok: true, rootInfo, workspaceRoot };
|
|
7684
8761
|
}
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
8762
|
+
|
|
8763
|
+
// src/workspace/worktree/select-members.ts
|
|
8764
|
+
function selectMembers(members, only) {
|
|
8765
|
+
if (!only || only.length === 0) return { selected: members, unknown: [] };
|
|
8766
|
+
const wanted = new Set(only);
|
|
8767
|
+
const selected = members.filter((m) => wanted.has(m.id) || wanted.has(m.folder));
|
|
8768
|
+
const matchedKeys = /* @__PURE__ */ new Set();
|
|
8769
|
+
for (const m of selected) {
|
|
8770
|
+
if (wanted.has(m.id)) matchedKeys.add(m.id);
|
|
8771
|
+
if (wanted.has(m.folder)) matchedKeys.add(m.folder);
|
|
8772
|
+
}
|
|
8773
|
+
const unknown = only.filter((name) => !matchedKeys.has(name));
|
|
8774
|
+
return { selected, unknown };
|
|
8775
|
+
}
|
|
8776
|
+
|
|
8777
|
+
// src/workspace/worktree/state.ts
|
|
8778
|
+
import path56 from "node:path";
|
|
8779
|
+
var WORKTREE_STATE_FILE = ".haus-worktree.json";
|
|
8780
|
+
function worktreesDir(mainRoot) {
|
|
8781
|
+
return path56.join(mainRoot, ".claude", "worktrees");
|
|
8782
|
+
}
|
|
8783
|
+
function worktreePath(mainRoot, slug) {
|
|
8784
|
+
return path56.join(worktreesDir(mainRoot), slug);
|
|
8785
|
+
}
|
|
8786
|
+
async function readWorktreeState(worktreeRoot) {
|
|
8787
|
+
return readJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE));
|
|
8788
|
+
}
|
|
8789
|
+
async function writeWorktreeState(worktreeRoot, state) {
|
|
8790
|
+
await writeJson(path56.join(worktreeRoot, WORKTREE_STATE_FILE), state);
|
|
8791
|
+
}
|
|
8792
|
+
|
|
8793
|
+
// src/workspace/worktree/add.ts
|
|
8794
|
+
async function runAdd(opts) {
|
|
8795
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
8796
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
8797
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
8798
|
+
const allMembers = await readMembers(rootInfo);
|
|
8799
|
+
const { selected, unknown } = selectMembers(allMembers, opts.only);
|
|
8800
|
+
if (unknown.length > 0) {
|
|
8801
|
+
return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
|
|
8802
|
+
}
|
|
8803
|
+
const branch = opts.branch ?? opts.slug;
|
|
8804
|
+
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
8805
|
+
if (await fs40.pathExists(wsWorktreePath)) {
|
|
8806
|
+
return {
|
|
8807
|
+
ok: false,
|
|
8808
|
+
error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
|
|
8809
|
+
};
|
|
8810
|
+
}
|
|
8811
|
+
if (opts.dryRun) {
|
|
8812
|
+
return runDryRun({ workspaceRoot, wsWorktreePath, slug: opts.slug, branch, selected });
|
|
8813
|
+
}
|
|
8814
|
+
const wsOutcome = await addWorktree(workspaceRoot, wsWorktreePath, branch);
|
|
8815
|
+
if (!wsOutcome.ok) {
|
|
8816
|
+
return {
|
|
8817
|
+
ok: false,
|
|
8818
|
+
error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo \u2014 pick a different --branch.` : `Failed to create the workspace worktree: ${wsOutcome.stderr ?? "unknown error"}`
|
|
8819
|
+
};
|
|
8820
|
+
}
|
|
8821
|
+
const memberResults = [];
|
|
8822
|
+
for (const member of selected) {
|
|
8823
|
+
const start = Date.now();
|
|
8824
|
+
const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
|
|
8825
|
+
const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
|
|
8826
|
+
preferDefaultBranchFrom: true
|
|
8827
|
+
});
|
|
8828
|
+
if (!outcome.ok) {
|
|
8829
|
+
memberResults.push({
|
|
8830
|
+
member: member.id,
|
|
8831
|
+
folder: member.folder,
|
|
8832
|
+
status: "failed",
|
|
8833
|
+
branch,
|
|
8834
|
+
branchAction: outcome.branchAction,
|
|
8835
|
+
error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id} \u2014 pick a different --branch.` : outcome.stderr ?? "unknown error",
|
|
8836
|
+
durationMs: Date.now() - start
|
|
8837
|
+
});
|
|
8838
|
+
continue;
|
|
8839
|
+
}
|
|
8840
|
+
memberResults.push({
|
|
8841
|
+
member: member.id,
|
|
8842
|
+
folder: member.folder,
|
|
8843
|
+
status: "ok",
|
|
8844
|
+
branch,
|
|
8845
|
+
branchAction: outcome.branchAction,
|
|
8846
|
+
startPoint: outcome.startPoint,
|
|
8847
|
+
durationMs: Date.now() - start
|
|
8848
|
+
});
|
|
8849
|
+
}
|
|
8850
|
+
const okResults = memberResults.filter((m) => m.status === "ok");
|
|
8851
|
+
await mapWithConcurrency(okResults, async (result) => {
|
|
8852
|
+
const member = selected.find((m) => m.id === result.member);
|
|
8853
|
+
if (!member) return;
|
|
8854
|
+
const memberWorktreePath = path57.join(wsWorktreePath, member.folder);
|
|
8855
|
+
const hydrationStart = Date.now();
|
|
8856
|
+
if (opts.hydrate !== false) {
|
|
8857
|
+
result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
|
|
8858
|
+
}
|
|
8859
|
+
result.localFilesCopied = await copyMachineLocalFiles(member.absPath, memberWorktreePath);
|
|
8860
|
+
result.durationMs += Date.now() - hydrationStart;
|
|
7696
8861
|
});
|
|
7697
|
-
const
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
8862
|
+
const stateMembers = okResults.map((m) => ({
|
|
8863
|
+
id: m.member,
|
|
8864
|
+
folder: m.folder,
|
|
8865
|
+
branch: m.branch,
|
|
8866
|
+
absPath: selected.find((s) => s.id === m.member)?.absPath
|
|
8867
|
+
}));
|
|
8868
|
+
await writeWorktreeState(wsWorktreePath, {
|
|
8869
|
+
slug: opts.slug,
|
|
8870
|
+
branch,
|
|
8871
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8872
|
+
members: stateMembers
|
|
8873
|
+
});
|
|
8874
|
+
const anyFailed = memberResults.some((m) => m.status === "failed");
|
|
8875
|
+
return {
|
|
8876
|
+
ok: !anyFailed,
|
|
8877
|
+
dryRun: false,
|
|
8878
|
+
workspaceRoot,
|
|
8879
|
+
slug: opts.slug,
|
|
8880
|
+
branch,
|
|
8881
|
+
worktreePath: wsWorktreePath,
|
|
8882
|
+
workspaceWorktree: { status: "ok", branchAction: wsOutcome.branchAction },
|
|
8883
|
+
members: memberResults
|
|
8884
|
+
};
|
|
8885
|
+
}
|
|
8886
|
+
async function runDryRun(args) {
|
|
8887
|
+
const { workspaceRoot, wsWorktreePath, slug, branch, selected } = args;
|
|
8888
|
+
const wsBranchExists = await branchExists(workspaceRoot, branch);
|
|
8889
|
+
const members = [];
|
|
8890
|
+
for (const member of selected) {
|
|
8891
|
+
const exists = await branchExists(member.absPath, branch);
|
|
8892
|
+
let startPoint;
|
|
8893
|
+
if (!exists) {
|
|
8894
|
+
const def = await resolveDefaultBranch(member.absPath);
|
|
8895
|
+
startPoint = def?.ref;
|
|
8896
|
+
}
|
|
8897
|
+
members.push({
|
|
8898
|
+
member: member.id,
|
|
8899
|
+
folder: member.folder,
|
|
8900
|
+
status: "planned",
|
|
8901
|
+
branch,
|
|
8902
|
+
branchAction: exists ? "checkout" : "create",
|
|
8903
|
+
startPoint,
|
|
8904
|
+
durationMs: 0
|
|
8905
|
+
});
|
|
7705
8906
|
}
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
8907
|
+
return {
|
|
8908
|
+
ok: true,
|
|
8909
|
+
dryRun: true,
|
|
8910
|
+
workspaceRoot,
|
|
8911
|
+
slug,
|
|
8912
|
+
branch,
|
|
8913
|
+
worktreePath: wsWorktreePath,
|
|
8914
|
+
workspaceWorktree: { status: "planned", branchAction: wsBranchExists ? "checkout" : "create" },
|
|
8915
|
+
members
|
|
8916
|
+
};
|
|
8917
|
+
}
|
|
8918
|
+
|
|
8919
|
+
// src/workspace/worktree/doctor.ts
|
|
8920
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
8921
|
+
import path58 from "node:path";
|
|
8922
|
+
var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
|
|
8923
|
+
function looksHydrated(memberPath) {
|
|
8924
|
+
return HYDRATION_MARKERS.some((marker) => existsSync5(path58.join(memberPath, marker)));
|
|
8925
|
+
}
|
|
8926
|
+
async function runDoctor2() {
|
|
8927
|
+
const rootInfo = await resolveRoots();
|
|
8928
|
+
if (!rootInfo.isGitRepo) {
|
|
8929
|
+
return {
|
|
8930
|
+
isWorkspace: false,
|
|
8931
|
+
inWorkspaceWorktree: false,
|
|
8932
|
+
members: [],
|
|
8933
|
+
orphans: [],
|
|
8934
|
+
problems: ["Not inside a git repository."]
|
|
8935
|
+
};
|
|
8936
|
+
}
|
|
8937
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
8938
|
+
const isWorkspace = existsSync5(path58.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path58.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
8939
|
+
if (!isWorkspace) {
|
|
8940
|
+
return {
|
|
8941
|
+
isWorkspace: false,
|
|
8942
|
+
inWorkspaceWorktree: false,
|
|
8943
|
+
members: [],
|
|
8944
|
+
orphans: [],
|
|
8945
|
+
problems: [
|
|
8946
|
+
`Not a haus workspace \u2014 no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
|
|
8947
|
+
]
|
|
8948
|
+
};
|
|
8949
|
+
}
|
|
8950
|
+
const wsWorktreesDir = worktreesDir(workspaceRoot);
|
|
8951
|
+
const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path58.sep}`);
|
|
8952
|
+
const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
|
|
8953
|
+
const members = await readMembers(rootInfo);
|
|
8954
|
+
const problems = [];
|
|
8955
|
+
const memberChecks = [];
|
|
8956
|
+
let slug;
|
|
8957
|
+
if (inWorkspaceWorktree) {
|
|
8958
|
+
const wtPath = rootInfo.repoRoot;
|
|
8959
|
+
slug = rootInfo.worktreeName ?? path58.basename(wtPath);
|
|
8960
|
+
const state = await readWorktreeState(wtPath);
|
|
8961
|
+
for (const member of members) {
|
|
8962
|
+
const memberPath = path58.join(wtPath, member.folder);
|
|
8963
|
+
const materialized = existsSync5(memberPath);
|
|
8964
|
+
const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
|
|
8965
|
+
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
8966
|
+
const branchMismatch = Boolean(
|
|
8967
|
+
expectedBranch && actualBranch && expectedBranch !== actualBranch
|
|
8968
|
+
);
|
|
8969
|
+
const hydrated = materialized && looksHydrated(memberPath);
|
|
8970
|
+
memberChecks.push({
|
|
8971
|
+
id: member.id,
|
|
8972
|
+
folder: member.folder,
|
|
8973
|
+
materialized,
|
|
8974
|
+
expectedBranch,
|
|
8975
|
+
actualBranch,
|
|
8976
|
+
branchMismatch,
|
|
8977
|
+
hydrated
|
|
8978
|
+
});
|
|
8979
|
+
if (!materialized) problems.push(`${member.id}: not materialized in this worktree.`);
|
|
8980
|
+
else if (branchMismatch) {
|
|
8981
|
+
problems.push(`${member.id}: on branch "${actualBranch}", expected "${expectedBranch}".`);
|
|
8982
|
+
} else if (!hydrated) {
|
|
8983
|
+
problems.push(
|
|
8984
|
+
`${member.id}: materialized but not hydrated (no node_modules/vendor/obj/bin). Run \`haus workspace worktree hydrate\` to fix.`
|
|
8985
|
+
);
|
|
8986
|
+
}
|
|
8987
|
+
}
|
|
8988
|
+
}
|
|
8989
|
+
const orphans = [];
|
|
8990
|
+
for (const member of members) {
|
|
8991
|
+
const entries = await listWorktrees(member.absPath);
|
|
8992
|
+
for (const entry of entries) {
|
|
8993
|
+
const entryPath = path58.normalize(entry.path);
|
|
8994
|
+
const marker = `${path58.sep}.claude${path58.sep}worktrees${path58.sep}`;
|
|
8995
|
+
const idx = entryPath.indexOf(marker);
|
|
8996
|
+
if (idx === -1) continue;
|
|
8997
|
+
const rest = entryPath.slice(idx + marker.length);
|
|
8998
|
+
const slugName = rest.split(path58.sep)[0];
|
|
8999
|
+
if (!slugName) continue;
|
|
9000
|
+
if (!existsSync5(path58.join(wsWorktreesDir, slugName))) {
|
|
9001
|
+
orphans.push(
|
|
9002
|
+
`${member.id}: worktree registered at ${entry.path} but ${path58.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
|
|
9003
|
+
);
|
|
9004
|
+
}
|
|
9005
|
+
}
|
|
9006
|
+
}
|
|
9007
|
+
if (orphans.length > 0) problems.push(...orphans);
|
|
9008
|
+
return { isWorkspace: true, inWorkspaceWorktree, slug, members: memberChecks, orphans, problems };
|
|
9009
|
+
}
|
|
9010
|
+
|
|
9011
|
+
// src/workspace/worktree/hydrate-command.ts
|
|
9012
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
9013
|
+
import path59 from "node:path";
|
|
9014
|
+
async function runHydrateWorktree(opts) {
|
|
9015
|
+
const rootInfo = await resolveRoots();
|
|
9016
|
+
if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
|
|
9017
|
+
return {
|
|
9018
|
+
ok: false,
|
|
9019
|
+
error: "Run this from inside a workspace worktree created by `haus workspace worktree add` (.claude/worktrees/<slug>)."
|
|
9020
|
+
};
|
|
9021
|
+
}
|
|
9022
|
+
const workspaceRoot = rootInfo.mainRoot;
|
|
9023
|
+
const wtPath = rootInfo.repoRoot;
|
|
9024
|
+
if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path59.sep}`)) {
|
|
9025
|
+
return {
|
|
9026
|
+
ok: false,
|
|
9027
|
+
error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
|
|
9028
|
+
};
|
|
9029
|
+
}
|
|
9030
|
+
const members = await readMembers(rootInfo);
|
|
9031
|
+
const { selected, unknown } = selectMembers(members, opts.only);
|
|
9032
|
+
if (unknown.length > 0) {
|
|
9033
|
+
return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
|
|
9034
|
+
}
|
|
9035
|
+
const state = await readWorktreeState(wtPath);
|
|
9036
|
+
const slug = state?.slug ?? path59.basename(wtPath);
|
|
9037
|
+
const detectedBranch = await currentBranch(wtPath);
|
|
9038
|
+
const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
|
|
9039
|
+
if (opts.dryRun) {
|
|
9040
|
+
const results2 = await Promise.all(
|
|
9041
|
+
selected.map(async (member) => {
|
|
9042
|
+
const memberPath = path59.join(wtPath, member.folder);
|
|
9043
|
+
if (existsSync6(memberPath)) {
|
|
9044
|
+
return { member: member.id, folder: member.folder, status: "planned" };
|
|
9045
|
+
}
|
|
9046
|
+
if (!opts.materialize) {
|
|
9047
|
+
return {
|
|
9048
|
+
member: member.id,
|
|
9049
|
+
folder: member.folder,
|
|
9050
|
+
status: "skipped",
|
|
9051
|
+
error: "not materialized in this worktree"
|
|
9052
|
+
};
|
|
9053
|
+
}
|
|
9054
|
+
const branchAction = await branchExists(member.absPath, targetBranch) ? "checkout" : "create";
|
|
9055
|
+
return { member: member.id, folder: member.folder, status: "planned", branchAction };
|
|
9056
|
+
})
|
|
9057
|
+
);
|
|
9058
|
+
return { ok: true, dryRun: true, slug, results: results2 };
|
|
9059
|
+
}
|
|
9060
|
+
const results = await mapWithConcurrency(
|
|
9061
|
+
selected,
|
|
9062
|
+
async (member) => {
|
|
9063
|
+
const memberPath = path59.join(wtPath, member.folder);
|
|
9064
|
+
let branchAction;
|
|
9065
|
+
if (!existsSync6(memberPath)) {
|
|
9066
|
+
if (!opts.materialize) {
|
|
9067
|
+
return {
|
|
9068
|
+
member: member.id,
|
|
9069
|
+
folder: member.folder,
|
|
9070
|
+
status: "skipped",
|
|
9071
|
+
error: "not materialized in this worktree"
|
|
9072
|
+
};
|
|
9073
|
+
}
|
|
9074
|
+
const outcome = await addWorktree(member.absPath, memberPath, targetBranch, {
|
|
9075
|
+
preferDefaultBranchFrom: true
|
|
9076
|
+
});
|
|
9077
|
+
if (!outcome.ok) {
|
|
9078
|
+
return {
|
|
9079
|
+
member: member.id,
|
|
9080
|
+
folder: member.folder,
|
|
9081
|
+
status: "failed",
|
|
9082
|
+
error: outcome.checkedOutElsewhere ? `Branch "${targetBranch}" is already checked out elsewhere in ${member.id} \u2014 remove that worktree or resolve the branch conflict first.` : outcome.stderr ?? "unknown error creating the member worktree"
|
|
9083
|
+
};
|
|
9084
|
+
}
|
|
9085
|
+
branchAction = outcome.branchAction;
|
|
9086
|
+
}
|
|
9087
|
+
try {
|
|
9088
|
+
const hydration = await hydrateMember(member.absPath, memberPath, { force: opts.force });
|
|
9089
|
+
return { member: member.id, folder: member.folder, status: "ok", hydration, branchAction };
|
|
9090
|
+
} catch (err) {
|
|
9091
|
+
return {
|
|
9092
|
+
member: member.id,
|
|
9093
|
+
folder: member.folder,
|
|
9094
|
+
status: "failed",
|
|
9095
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9096
|
+
branchAction
|
|
9097
|
+
};
|
|
9098
|
+
}
|
|
9099
|
+
}
|
|
7709
9100
|
);
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
9101
|
+
const newlyMaterialized = results.filter((r) => r.branchAction && r.status === "ok");
|
|
9102
|
+
if (newlyMaterialized.length > 0) {
|
|
9103
|
+
const byId = new Map((state?.members ?? []).map((m) => [m.id, m]));
|
|
9104
|
+
for (const r of newlyMaterialized) {
|
|
9105
|
+
const member = selected.find((m) => m.id === r.member);
|
|
9106
|
+
if (!member) continue;
|
|
9107
|
+
const entry = {
|
|
9108
|
+
id: member.id,
|
|
9109
|
+
folder: member.folder,
|
|
9110
|
+
branch: targetBranch,
|
|
9111
|
+
absPath: member.absPath
|
|
9112
|
+
};
|
|
9113
|
+
byId.set(member.id, entry);
|
|
9114
|
+
}
|
|
9115
|
+
await writeWorktreeState(wtPath, {
|
|
9116
|
+
slug,
|
|
9117
|
+
branch: state?.branch ?? targetBranch,
|
|
9118
|
+
createdAt: state?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
9119
|
+
members: Array.from(byId.values())
|
|
9120
|
+
});
|
|
7714
9121
|
}
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
9122
|
+
return { ok: results.every((r) => r.status !== "failed"), dryRun: false, slug, results };
|
|
9123
|
+
}
|
|
9124
|
+
|
|
9125
|
+
// src/workspace/worktree/list.ts
|
|
9126
|
+
import path60 from "node:path";
|
|
9127
|
+
import fs41 from "fs-extra";
|
|
9128
|
+
async function runList2() {
|
|
9129
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
9130
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
9131
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
9132
|
+
const dir = worktreesDir(workspaceRoot);
|
|
9133
|
+
if (!await fs41.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
9134
|
+
const entries = await fs41.readdir(dir, { withFileTypes: true });
|
|
9135
|
+
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
9136
|
+
const configuredMembers = await readMembers(rootInfo);
|
|
9137
|
+
const worktrees = [];
|
|
9138
|
+
for (const slug of slugs) {
|
|
9139
|
+
const wtPath = path60.join(dir, slug);
|
|
9140
|
+
const state = await readWorktreeState(wtPath);
|
|
9141
|
+
const members = [];
|
|
9142
|
+
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
9143
|
+
for (const m of source) {
|
|
9144
|
+
const memberPath = path60.join(wtPath, m.folder);
|
|
9145
|
+
const materialized = await fs41.pathExists(memberPath);
|
|
9146
|
+
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
9147
|
+
members.push({
|
|
9148
|
+
id: m.id,
|
|
9149
|
+
folder: m.folder,
|
|
9150
|
+
expectedBranch: m.branch,
|
|
9151
|
+
materialized,
|
|
9152
|
+
actualBranch
|
|
9153
|
+
});
|
|
7725
9154
|
}
|
|
7726
|
-
|
|
7727
|
-
|
|
9155
|
+
worktrees.push({
|
|
9156
|
+
slug,
|
|
9157
|
+
branch: state?.branch ?? await currentBranch(wtPath) ?? "unknown",
|
|
9158
|
+
createdAt: state?.createdAt,
|
|
9159
|
+
members
|
|
9160
|
+
});
|
|
9161
|
+
}
|
|
9162
|
+
return { ok: true, workspaceRoot, worktrees };
|
|
9163
|
+
}
|
|
9164
|
+
|
|
9165
|
+
// src/workspace/worktree/remove.ts
|
|
9166
|
+
import path61 from "node:path";
|
|
9167
|
+
import fs42 from "fs-extra";
|
|
9168
|
+
async function runRemove(opts) {
|
|
9169
|
+
const resolved = await resolveWorkspaceForWorktree();
|
|
9170
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
9171
|
+
const { rootInfo, workspaceRoot } = resolved;
|
|
9172
|
+
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
9173
|
+
if (!await fs42.pathExists(wsWorktreePath)) {
|
|
9174
|
+
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
9175
|
+
}
|
|
9176
|
+
const state = await readWorktreeState(wsWorktreePath);
|
|
9177
|
+
const allMembers = await readMembers(rootInfo);
|
|
9178
|
+
const stateMembers = 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 }));
|
|
9179
|
+
const targets = [
|
|
9180
|
+
{ repo: "(workspace)", ownerRepo: workspaceRoot, path: wsWorktreePath }
|
|
9181
|
+
];
|
|
9182
|
+
const unresolvedDroppedMembers = [];
|
|
9183
|
+
for (const stateMember of stateMembers) {
|
|
9184
|
+
const configMember = allMembers.find((m) => m.folder === stateMember.folder);
|
|
9185
|
+
const ownerRepo = configMember?.absPath ?? stateMember.absPath;
|
|
9186
|
+
if (!ownerRepo) {
|
|
9187
|
+
if (!configMember) unresolvedDroppedMembers.push(stateMember.folder);
|
|
9188
|
+
continue;
|
|
9189
|
+
}
|
|
9190
|
+
targets.push({
|
|
9191
|
+
repo: configMember?.id ?? stateMember.id,
|
|
9192
|
+
ownerRepo,
|
|
9193
|
+
path: path61.join(wsWorktreePath, stateMember.folder)
|
|
9194
|
+
});
|
|
9195
|
+
}
|
|
9196
|
+
const existingTargets = [];
|
|
9197
|
+
for (const target of targets) {
|
|
9198
|
+
if (await fs42.pathExists(target.path)) existingTargets.push(target);
|
|
9199
|
+
}
|
|
9200
|
+
if (!opts.force) {
|
|
9201
|
+
const blockers = [];
|
|
9202
|
+
for (const folder of unresolvedDroppedMembers) {
|
|
9203
|
+
blockers.push({
|
|
9204
|
+
repo: folder,
|
|
9205
|
+
reason: "no longer in the workspace config and this worktree predates absPath tracking \u2014 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)"
|
|
9206
|
+
});
|
|
9207
|
+
}
|
|
9208
|
+
for (const target of existingTargets) {
|
|
9209
|
+
const ignorePaths = target.repo === "(workspace)" ? [WORKTREE_STATE_FILE, ...stateMembers.map((m) => m.folder)] : [];
|
|
9210
|
+
if (await hasUncommittedChanges(target.path, ignorePaths)) {
|
|
9211
|
+
blockers.push({ repo: target.repo, reason: "uncommitted changes" });
|
|
9212
|
+
continue;
|
|
9213
|
+
}
|
|
9214
|
+
const unpushed = await hasUnpushedWork(target.path);
|
|
9215
|
+
if (unpushed.unpushed) blockers.push({ repo: target.repo, reason: unpushed.reason });
|
|
9216
|
+
}
|
|
9217
|
+
if (blockers.length > 0) return { ok: false, blocked: true, blockers };
|
|
9218
|
+
}
|
|
9219
|
+
if (opts.dryRun) {
|
|
9220
|
+
return {
|
|
9221
|
+
ok: true,
|
|
9222
|
+
dryRun: true,
|
|
9223
|
+
slug: opts.slug,
|
|
9224
|
+
wouldRemove: existingTargets.map((t) => t.repo)
|
|
9225
|
+
};
|
|
9226
|
+
}
|
|
9227
|
+
const removed = [];
|
|
9228
|
+
const failed = [];
|
|
9229
|
+
for (const folder of unresolvedDroppedMembers) {
|
|
9230
|
+
failed.push({
|
|
9231
|
+
repo: folder,
|
|
9232
|
+
error: "directory removed, but its git worktree registration could not be unregistered (no recorded absPath) \u2014 run `git worktree prune` in its original owning repo"
|
|
9233
|
+
});
|
|
9234
|
+
}
|
|
9235
|
+
const memberTargets = existingTargets.filter((t) => t.repo !== "(workspace)");
|
|
9236
|
+
for (const target of memberTargets) {
|
|
9237
|
+
const result = await removeWorktree(target.ownerRepo, target.path);
|
|
9238
|
+
if (result.ok) removed.push(target.repo);
|
|
9239
|
+
else failed.push({ repo: target.repo, error: result.stderr ?? "unknown error" });
|
|
9240
|
+
await pruneWorktrees(target.ownerRepo);
|
|
9241
|
+
}
|
|
9242
|
+
const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
|
|
9243
|
+
if (wsTarget) {
|
|
9244
|
+
const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
|
|
9245
|
+
if (wsResult.ok) removed.push("(workspace)");
|
|
9246
|
+
else failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
|
|
9247
|
+
await pruneWorktrees(workspaceRoot);
|
|
9248
|
+
}
|
|
9249
|
+
if (await fs42.pathExists(wsWorktreePath)) {
|
|
9250
|
+
await fs42.remove(wsWorktreePath);
|
|
9251
|
+
}
|
|
9252
|
+
return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
|
|
7728
9253
|
}
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
9254
|
+
|
|
9255
|
+
// src/commands/workspace/worktree.ts
|
|
9256
|
+
function normalizeOnly(only) {
|
|
9257
|
+
if (!only) return void 0;
|
|
9258
|
+
const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
|
|
9259
|
+
const cleaned = list.map((s) => s.trim()).filter(Boolean);
|
|
9260
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
9261
|
+
}
|
|
9262
|
+
function describeInstall(hydration) {
|
|
9263
|
+
if (hydration.install.plan.manager === "none") return "no install step (no recognized lockfile)";
|
|
9264
|
+
if (!hydration.install.ran)
|
|
9265
|
+
return `install skipped (${hydration.install.skippedReason ?? "unavailable"})`;
|
|
9266
|
+
return hydration.install.ok ? `hydrated via ${hydration.install.plan.manager}` : `install FAILED via ${hydration.install.plan.manager}`;
|
|
9267
|
+
}
|
|
9268
|
+
async function handleAdd(opts) {
|
|
9269
|
+
if (!opts.slug) {
|
|
9270
|
+
error("Usage: haus workspace worktree add <slug>");
|
|
9271
|
+
process.exitCode = 1;
|
|
9272
|
+
return;
|
|
9273
|
+
}
|
|
9274
|
+
const addOpts = {
|
|
9275
|
+
slug: opts.slug,
|
|
9276
|
+
branch: opts.branch,
|
|
9277
|
+
only: normalizeOnly(opts.only),
|
|
9278
|
+
hydrate: opts.hydrate !== false,
|
|
9279
|
+
dryRun: Boolean(opts.dryRun)
|
|
9280
|
+
};
|
|
9281
|
+
const result = await runAdd(addOpts);
|
|
9282
|
+
if ("error" in result) {
|
|
9283
|
+
error(result.error);
|
|
9284
|
+
process.exitCode = 1;
|
|
9285
|
+
return;
|
|
9286
|
+
}
|
|
9287
|
+
if (result.dryRun) {
|
|
9288
|
+
log(
|
|
9289
|
+
`[dry-run] Would create the workspace worktree at ${result.worktreePath} on branch "${result.branch}" (${result.workspaceWorktree.branchAction}).`
|
|
9290
|
+
);
|
|
9291
|
+
for (const m of result.members) {
|
|
9292
|
+
log(
|
|
9293
|
+
`[dry-run] ${m.member} (${m.folder}): ${m.branchAction}${m.startPoint ? ` from ${m.startPoint}` : ""}`
|
|
9294
|
+
);
|
|
7735
9295
|
}
|
|
9296
|
+
return;
|
|
7736
9297
|
}
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
9298
|
+
log(
|
|
9299
|
+
`Workspace worktree "${result.slug}" ready at ${result.worktreePath} (branch "${result.branch}", ${result.workspaceWorktree.branchAction}).`
|
|
9300
|
+
);
|
|
9301
|
+
for (const m of result.members) {
|
|
9302
|
+
if (m.status === "ok") {
|
|
9303
|
+
const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
|
|
9304
|
+
log(` ${m.member}: ${m.branchAction} "${m.branch}" \u2014 ${hydrationNote} (${m.durationMs}ms)`);
|
|
9305
|
+
} else {
|
|
9306
|
+
warn(` ${m.member}: FAILED \u2014 ${m.error}`);
|
|
9307
|
+
}
|
|
9308
|
+
}
|
|
9309
|
+
if (!result.ok) {
|
|
9310
|
+
const failedCount = result.members.filter((m) => m.status === "failed").length;
|
|
9311
|
+
error(
|
|
9312
|
+
`${failedCount} member(s) failed to materialize \u2014 the worktree is partially built. See the per-member detail above for exactly what exists.`
|
|
9313
|
+
);
|
|
9314
|
+
process.exitCode = 1;
|
|
9315
|
+
}
|
|
9316
|
+
}
|
|
9317
|
+
async function handleHydrate(opts) {
|
|
9318
|
+
const hydrateOpts = {
|
|
9319
|
+
only: normalizeOnly(opts.only),
|
|
9320
|
+
force: Boolean(opts.force),
|
|
9321
|
+
dryRun: Boolean(opts.dryRun),
|
|
9322
|
+
materialize: Boolean(opts.materialize)
|
|
9323
|
+
};
|
|
9324
|
+
const result = await runHydrateWorktree(hydrateOpts);
|
|
9325
|
+
if (!result.ok && "error" in result) {
|
|
9326
|
+
error(result.error);
|
|
9327
|
+
process.exitCode = 1;
|
|
9328
|
+
return;
|
|
9329
|
+
}
|
|
9330
|
+
if (result.dryRun) {
|
|
9331
|
+
log(`[dry-run] Would hydrate "${result.slug}":`);
|
|
9332
|
+
for (const r of result.results) {
|
|
9333
|
+
const action = r.branchAction ? ` (${r.branchAction} member worktree first)` : "";
|
|
9334
|
+
log(`[dry-run] ${r.member}: ${r.status}${action}${r.error ? ` (${r.error})` : ""}`);
|
|
9335
|
+
}
|
|
9336
|
+
return;
|
|
9337
|
+
}
|
|
9338
|
+
log(`Hydrated "${result.slug}":`);
|
|
9339
|
+
for (const r of result.results) {
|
|
9340
|
+
if (r.status === "ok" && r.hydration) {
|
|
9341
|
+
const materializedNote = r.branchAction ? `materialized (${r.branchAction}), ` : "";
|
|
9342
|
+
log(` ${r.member}: ${materializedNote}${describeInstall(r.hydration)}`);
|
|
9343
|
+
} else {
|
|
9344
|
+
warn(` ${r.member}: ${r.status}${r.error ? ` \u2014 ${r.error}` : ""}`);
|
|
9345
|
+
}
|
|
7742
9346
|
}
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
9347
|
+
if (!result.ok) process.exitCode = 1;
|
|
9348
|
+
}
|
|
9349
|
+
async function handleRemove(opts) {
|
|
9350
|
+
if (!opts.slug) {
|
|
9351
|
+
error("Usage: haus workspace worktree remove <slug>");
|
|
9352
|
+
process.exitCode = 1;
|
|
9353
|
+
return;
|
|
7747
9354
|
}
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
9355
|
+
const removeOpts = {
|
|
9356
|
+
slug: opts.slug,
|
|
9357
|
+
force: Boolean(opts.force),
|
|
9358
|
+
dryRun: Boolean(opts.dryRun)
|
|
7752
9359
|
};
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
client: config.client,
|
|
7757
|
-
repos: config.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
|
|
7758
|
-
relationships: config.relationships
|
|
7759
|
-
});
|
|
7760
|
-
}
|
|
7761
|
-
async function runDiscover(workspaceRoot, opts = {}) {
|
|
7762
|
-
const yamlPath = path46.join(workspaceRoot, "haus.workspace.yaml");
|
|
7763
|
-
const existingText = await readText(yamlPath);
|
|
7764
|
-
const existing = parseWorkspaceConfig(existingText);
|
|
7765
|
-
if (existingText && !existing) {
|
|
7766
|
-
error(
|
|
7767
|
-
"Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
|
|
7768
|
-
);
|
|
9360
|
+
const result = await runRemove(removeOpts);
|
|
9361
|
+
if (!result.ok && "error" in result) {
|
|
9362
|
+
error(result.error);
|
|
7769
9363
|
process.exitCode = 1;
|
|
7770
9364
|
return;
|
|
7771
9365
|
}
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
9366
|
+
if (!result.ok && "blocked" in result && result.blocked) {
|
|
9367
|
+
error(`Refusing to remove "${opts.slug}" \u2014 uncommitted or unpushed work found:`);
|
|
9368
|
+
for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
|
|
9369
|
+
error("Re-run with --force to remove anyway.");
|
|
7775
9370
|
process.exitCode = 1;
|
|
7776
9371
|
return;
|
|
7777
9372
|
}
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
9373
|
+
if ("dryRun" in result && result.dryRun) {
|
|
9374
|
+
log(`[dry-run] Would remove: ${result.wouldRemove.join(", ") || "(nothing)"}`);
|
|
9375
|
+
return;
|
|
9376
|
+
}
|
|
9377
|
+
if ("removed" in result) {
|
|
9378
|
+
log(`Removed: ${result.removed.join(", ") || "(nothing)"}`);
|
|
9379
|
+
if (result.failed.length > 0) {
|
|
9380
|
+
for (const f of result.failed) error(` Failed to remove ${f.repo}: ${f.error}`);
|
|
9381
|
+
process.exitCode = 1;
|
|
7786
9382
|
}
|
|
9383
|
+
}
|
|
9384
|
+
}
|
|
9385
|
+
async function handleList() {
|
|
9386
|
+
const result = await runList2();
|
|
9387
|
+
if (!result.ok) {
|
|
9388
|
+
error(result.error);
|
|
9389
|
+
process.exitCode = 1;
|
|
7787
9390
|
return;
|
|
7788
9391
|
}
|
|
7789
|
-
if (
|
|
7790
|
-
log(
|
|
9392
|
+
if (result.worktrees.length === 0) {
|
|
9393
|
+
log("No workspace worktrees under .claude/worktrees/.");
|
|
7791
9394
|
return;
|
|
7792
9395
|
}
|
|
7793
|
-
|
|
7794
|
-
|
|
9396
|
+
for (const wt of result.worktrees) {
|
|
9397
|
+
log(`${wt.slug} (branch: ${wt.branch})`);
|
|
9398
|
+
for (const m of wt.members) {
|
|
9399
|
+
const status = m.materialized ? `ok, branch ${m.actualBranch ?? "unknown"}` : "MISSING";
|
|
9400
|
+
log(` ${m.id}: ${status}`);
|
|
9401
|
+
}
|
|
9402
|
+
}
|
|
9403
|
+
}
|
|
9404
|
+
function emitFromHookContext(additionalContext) {
|
|
9405
|
+
log(
|
|
9406
|
+
JSON.stringify({
|
|
9407
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext }
|
|
9408
|
+
})
|
|
9409
|
+
);
|
|
9410
|
+
}
|
|
9411
|
+
async function handleDoctor(opts) {
|
|
9412
|
+
try {
|
|
9413
|
+
const report = await runDoctor2();
|
|
9414
|
+
if (!report.isWorkspace) {
|
|
9415
|
+
if (!opts.fromHook) {
|
|
9416
|
+
warn(report.problems[0] ?? "Not a haus workspace.");
|
|
9417
|
+
process.exitCode = 1;
|
|
9418
|
+
}
|
|
9419
|
+
return;
|
|
9420
|
+
}
|
|
9421
|
+
if (report.problems.length === 0) {
|
|
9422
|
+
if (!opts.fromHook) {
|
|
9423
|
+
log(
|
|
9424
|
+
report.inWorkspaceWorktree ? `Workspace worktree "${report.slug}" looks healthy.` : "No issues found."
|
|
9425
|
+
);
|
|
9426
|
+
}
|
|
9427
|
+
return;
|
|
9428
|
+
}
|
|
9429
|
+
if (opts.fromHook) {
|
|
9430
|
+
const scope = report.slug ? `Workspace worktree "${report.slug}"` : "This haus workspace";
|
|
9431
|
+
emitFromHookContext(
|
|
9432
|
+
`${scope} has ${report.problems.length} issue(s): ${report.problems.join("; ")}`
|
|
9433
|
+
);
|
|
9434
|
+
return;
|
|
9435
|
+
}
|
|
9436
|
+
for (const p of report.problems) warn(p);
|
|
9437
|
+
process.exitCode = 1;
|
|
9438
|
+
} catch (err) {
|
|
9439
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
9440
|
+
if (opts.fromHook) {
|
|
9441
|
+
emitFromHookContext(`\`haus workspace worktree doctor\` failed: ${message}`);
|
|
9442
|
+
} else {
|
|
9443
|
+
error(message);
|
|
9444
|
+
process.exitCode = 1;
|
|
9445
|
+
}
|
|
9446
|
+
}
|
|
9447
|
+
}
|
|
9448
|
+
async function runWorktree(action, opts = {}) {
|
|
9449
|
+
switch (action) {
|
|
9450
|
+
case "add":
|
|
9451
|
+
return handleAdd(opts);
|
|
9452
|
+
case "hydrate":
|
|
9453
|
+
return handleHydrate(opts);
|
|
9454
|
+
case "remove":
|
|
9455
|
+
return handleRemove(opts);
|
|
9456
|
+
case "list":
|
|
9457
|
+
return handleList();
|
|
9458
|
+
case "doctor":
|
|
9459
|
+
return handleDoctor(opts);
|
|
9460
|
+
}
|
|
9461
|
+
}
|
|
9462
|
+
|
|
9463
|
+
// src/commands/workspace.ts
|
|
9464
|
+
import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
|
|
9465
|
+
import path70 from "node:path";
|
|
9466
|
+
|
|
9467
|
+
// src/commands/workspace/aggregate.ts
|
|
9468
|
+
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
9469
|
+
const summaries = repos.map((repo) => ({
|
|
9470
|
+
name: repo.name,
|
|
9471
|
+
path: repo.path,
|
|
9472
|
+
roles: repo.context.repoRoles ?? [],
|
|
9473
|
+
packageManager: repo.context.packageManager,
|
|
9474
|
+
deps: repo.context.dependencies ?? []
|
|
9475
|
+
}));
|
|
9476
|
+
const ownership = {};
|
|
9477
|
+
for (const repo of summaries) {
|
|
9478
|
+
for (const dep2 of repo.deps) {
|
|
9479
|
+
ownership[dep2] ??= [];
|
|
9480
|
+
ownership[dep2].push(repo.name);
|
|
9481
|
+
}
|
|
9482
|
+
}
|
|
9483
|
+
const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
|
|
9484
|
+
const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
|
|
9485
|
+
const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
|
|
9486
|
+
const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
|
|
9487
|
+
const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
|
|
9488
|
+
const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
|
|
9489
|
+
await writeJson(summaryPath, {
|
|
9490
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9491
|
+
repos: summaries
|
|
9492
|
+
});
|
|
9493
|
+
await writeJson(ownershipPath, ownership);
|
|
9494
|
+
await writeText(
|
|
9495
|
+
crossRepoPath,
|
|
9496
|
+
`# Cross Repo Summary
|
|
9497
|
+
|
|
9498
|
+
${summaries.map(
|
|
9499
|
+
(repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
|
|
9500
|
+
).join("\n")}
|
|
9501
|
+
`
|
|
9502
|
+
);
|
|
9503
|
+
await writeJson(contextMapPath, {
|
|
9504
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9505
|
+
roles,
|
|
9506
|
+
crossRepoHints,
|
|
9507
|
+
repos: summaries.map((r) => ({
|
|
9508
|
+
name: r.name,
|
|
9509
|
+
path: r.path,
|
|
9510
|
+
roles: r.roles,
|
|
9511
|
+
packageManager: r.packageManager
|
|
9512
|
+
})),
|
|
9513
|
+
relationships
|
|
9514
|
+
});
|
|
9515
|
+
return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
|
|
7795
9516
|
}
|
|
7796
9517
|
|
|
7797
9518
|
// src/commands/workspace/doctor.ts
|
|
7798
|
-
import { existsSync as
|
|
7799
|
-
import
|
|
9519
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
9520
|
+
import path63 from "node:path";
|
|
7800
9521
|
|
|
7801
9522
|
// src/commands/workspace/manifest.ts
|
|
7802
|
-
import { readFileSync as
|
|
7803
|
-
import
|
|
9523
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
9524
|
+
import path62 from "node:path";
|
|
7804
9525
|
var MANIFEST_FILE = "workspace.manifest.json";
|
|
7805
9526
|
function manifestPath(workspaceRoot) {
|
|
7806
9527
|
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
7807
9528
|
}
|
|
7808
9529
|
function hausVersion() {
|
|
7809
9530
|
try {
|
|
7810
|
-
const pkg = JSON.parse(
|
|
9531
|
+
const pkg = JSON.parse(readFileSync4(path62.join(packageRoot(), "package.json"), "utf8"));
|
|
7811
9532
|
return pkg.version ?? "0.0.0";
|
|
7812
9533
|
} catch {
|
|
7813
9534
|
return "0.0.0";
|
|
@@ -7831,7 +9552,8 @@ function buildManifest2(opts) {
|
|
|
7831
9552
|
catalogRef: repo.catalogRef,
|
|
7832
9553
|
status: repo.status,
|
|
7833
9554
|
...repo.error ? { error: repo.error } : {}
|
|
7834
|
-
}))
|
|
9555
|
+
})),
|
|
9556
|
+
...opts.linkedContext ? { linkedContext: opts.linkedContext } : {}
|
|
7835
9557
|
};
|
|
7836
9558
|
}
|
|
7837
9559
|
async function readManifest2(workspaceRoot) {
|
|
@@ -7842,6 +9564,22 @@ async function writeWorkspaceManifest(workspaceRoot, manifest) {
|
|
|
7842
9564
|
await writeJson(target, manifest);
|
|
7843
9565
|
return target;
|
|
7844
9566
|
}
|
|
9567
|
+
async function readLinkedContext(workspaceRoot) {
|
|
9568
|
+
const manifest = await readManifest2(workspaceRoot);
|
|
9569
|
+
return manifest?.linkedContext ?? [];
|
|
9570
|
+
}
|
|
9571
|
+
async function writeLinkedContext(workspaceRoot, linkedContext) {
|
|
9572
|
+
const prior = await readManifest2(workspaceRoot);
|
|
9573
|
+
const next = prior ? { ...prior, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), linkedContext } : {
|
|
9574
|
+
version: 1,
|
|
9575
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9576
|
+
hausVersion: hausVersion(),
|
|
9577
|
+
client: "unknown",
|
|
9578
|
+
repos: [],
|
|
9579
|
+
linkedContext
|
|
9580
|
+
};
|
|
9581
|
+
return writeWorkspaceManifest(workspaceRoot, next);
|
|
9582
|
+
}
|
|
7845
9583
|
|
|
7846
9584
|
// src/commands/workspace/doctor.ts
|
|
7847
9585
|
async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
@@ -7874,7 +9612,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7874
9612
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
7875
9613
|
const catalogRefByRepo = [];
|
|
7876
9614
|
for (const repo of config.repos) {
|
|
7877
|
-
const repoRoot =
|
|
9615
|
+
const repoRoot = path63.resolve(workspaceRoot, repo.path);
|
|
7878
9616
|
const entry = manifestByName.get(repo.name);
|
|
7879
9617
|
if (!entry) {
|
|
7880
9618
|
flag({
|
|
@@ -7899,7 +9637,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7899
9637
|
detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
|
|
7900
9638
|
});
|
|
7901
9639
|
}
|
|
7902
|
-
if (!
|
|
9640
|
+
if (!existsSync7(claudePath(repoRoot))) {
|
|
7903
9641
|
flag({
|
|
7904
9642
|
repo: repo.name,
|
|
7905
9643
|
kind: "missing-claude",
|
|
@@ -7908,7 +9646,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7908
9646
|
}
|
|
7909
9647
|
const lock = await checkLock(repoRoot);
|
|
7910
9648
|
if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
|
|
7911
|
-
if (!
|
|
9649
|
+
if (!existsSync7(hausPath(repoRoot, "haus.lock.json"))) {
|
|
7912
9650
|
flag({
|
|
7913
9651
|
repo: repo.name,
|
|
7914
9652
|
kind: "missing-lock",
|
|
@@ -7933,6 +9671,40 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
7933
9671
|
detail: `Repos are on different catalog refs \u2014 ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
|
|
7934
9672
|
});
|
|
7935
9673
|
}
|
|
9674
|
+
if (manifest.linkedContext && manifest.linkedContext.length > 0) {
|
|
9675
|
+
const rootInfo = await resolveRoots(workspaceRoot);
|
|
9676
|
+
const members = await readMembers(rootInfo);
|
|
9677
|
+
const memberById = new Map(members.map((m) => [m.id, m]));
|
|
9678
|
+
for (const entry of manifest.linkedContext) {
|
|
9679
|
+
const member = memberById.get(entry.repo);
|
|
9680
|
+
if (!member || !existsSync7(member.absPath)) {
|
|
9681
|
+
flag({
|
|
9682
|
+
repo: entry.repo,
|
|
9683
|
+
kind: "missing-linked-context-source",
|
|
9684
|
+
detail: `Linked ${entry.type} "${entry.name}" (${entry.path}) \u2014 source repo no longer configured or cloned. Re-run \`haus workspace link-context\` to clean up.`
|
|
9685
|
+
});
|
|
9686
|
+
continue;
|
|
9687
|
+
}
|
|
9688
|
+
if (!existsSync7(path63.join(workspaceRoot, entry.path))) {
|
|
9689
|
+
flag({
|
|
9690
|
+
repo: entry.repo,
|
|
9691
|
+
kind: "missing-linked-context-copy",
|
|
9692
|
+
detail: `Linked ${entry.type} "${entry.name}" is recorded in the manifest but ${entry.path} no longer exists at the workspace root. Re-run \`haus workspace link-context\` to restore it.`
|
|
9693
|
+
});
|
|
9694
|
+
continue;
|
|
9695
|
+
}
|
|
9696
|
+
const liveHash = await hashInstalledPaths(member.absPath, [entry.sourceRelPath], {
|
|
9697
|
+
followSymlinks: false
|
|
9698
|
+
});
|
|
9699
|
+
if (liveHash !== entry.sourceHash) {
|
|
9700
|
+
flag({
|
|
9701
|
+
repo: entry.repo,
|
|
9702
|
+
kind: "stale-linked-context",
|
|
9703
|
+
detail: `Linked ${entry.type} "${entry.name}" (${entry.path}) is stale \u2014 the source in ${entry.repo} changed since it was linked. Re-run \`haus workspace link-context\`.`
|
|
9704
|
+
});
|
|
9705
|
+
}
|
|
9706
|
+
}
|
|
9707
|
+
}
|
|
7936
9708
|
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
7937
9709
|
}
|
|
7938
9710
|
function emit(args) {
|
|
@@ -7955,13 +9727,256 @@ function emit(args) {
|
|
|
7955
9727
|
return { workspaceRoot, manifest, drift };
|
|
7956
9728
|
}
|
|
7957
9729
|
|
|
9730
|
+
// src/workspace/link-context/link.ts
|
|
9731
|
+
import path66 from "node:path";
|
|
9732
|
+
import fs45 from "fs-extra";
|
|
9733
|
+
|
|
9734
|
+
// src/workspace/link-context/plan.ts
|
|
9735
|
+
import path65 from "node:path";
|
|
9736
|
+
import fs44 from "fs-extra";
|
|
9737
|
+
|
|
9738
|
+
// src/workspace/link-context/scan-source.ts
|
|
9739
|
+
import path64 from "node:path";
|
|
9740
|
+
import fs43 from "fs-extra";
|
|
9741
|
+
var ASSET_SUBDIR = {
|
|
9742
|
+
skill: "skills",
|
|
9743
|
+
agent: "agents",
|
|
9744
|
+
command: "commands"
|
|
9745
|
+
};
|
|
9746
|
+
async function hasLinkableContext(member) {
|
|
9747
|
+
for (const subdir of Object.values(ASSET_SUBDIR)) {
|
|
9748
|
+
if (await fs43.pathExists(path64.join(member.absPath, ".claude", subdir))) return true;
|
|
9749
|
+
}
|
|
9750
|
+
return false;
|
|
9751
|
+
}
|
|
9752
|
+
async function collectSkills(member) {
|
|
9753
|
+
const dir = path64.join(member.absPath, ".claude", "skills");
|
|
9754
|
+
if (!await fs43.pathExists(dir)) return [];
|
|
9755
|
+
const names = await fs43.readdir(dir);
|
|
9756
|
+
const out = [];
|
|
9757
|
+
for (const name of [...names].sort()) {
|
|
9758
|
+
const abs = path64.join(dir, name);
|
|
9759
|
+
const stat = await fs43.lstat(abs);
|
|
9760
|
+
if (!stat.isDirectory()) continue;
|
|
9761
|
+
if (!await fs43.pathExists(path64.join(abs, "SKILL.md"))) continue;
|
|
9762
|
+
out.push({ type: "skill", name, sourceRelPath: path64.posix.join(".claude", "skills", name) });
|
|
9763
|
+
}
|
|
9764
|
+
return out;
|
|
9765
|
+
}
|
|
9766
|
+
async function collectFlatMarkdown(member, type) {
|
|
9767
|
+
const subdir = ASSET_SUBDIR[type];
|
|
9768
|
+
const dir = path64.join(member.absPath, ".claude", subdir);
|
|
9769
|
+
if (!await fs43.pathExists(dir)) return [];
|
|
9770
|
+
const names = await fs43.readdir(dir);
|
|
9771
|
+
const out = [];
|
|
9772
|
+
for (const fileName of [...names].sort()) {
|
|
9773
|
+
if (!fileName.endsWith(".md")) continue;
|
|
9774
|
+
const abs = path64.join(dir, fileName);
|
|
9775
|
+
if (!(await fs43.lstat(abs)).isFile()) continue;
|
|
9776
|
+
const name = fileName.slice(0, -".md".length);
|
|
9777
|
+
out.push({ type, name, sourceRelPath: path64.posix.join(".claude", subdir, fileName) });
|
|
9778
|
+
}
|
|
9779
|
+
return out;
|
|
9780
|
+
}
|
|
9781
|
+
async function collectSourceAssets(member) {
|
|
9782
|
+
const [skills, agents, commands] = await Promise.all([
|
|
9783
|
+
collectSkills(member),
|
|
9784
|
+
collectFlatMarkdown(member, "agent"),
|
|
9785
|
+
collectFlatMarkdown(member, "command")
|
|
9786
|
+
]);
|
|
9787
|
+
return [...skills, ...agents, ...commands];
|
|
9788
|
+
}
|
|
9789
|
+
|
|
9790
|
+
// src/workspace/link-context/plan.ts
|
|
9791
|
+
function repoFolderPrefix(member) {
|
|
9792
|
+
const base = path65.posix.basename(member.folder);
|
|
9793
|
+
if (base && base !== "." && base !== "/") return base;
|
|
9794
|
+
const fromAbs = path65.basename(member.absPath);
|
|
9795
|
+
return fromAbs || member.id;
|
|
9796
|
+
}
|
|
9797
|
+
function assetKey(type, destKey) {
|
|
9798
|
+
return `${type}:${destKey}`;
|
|
9799
|
+
}
|
|
9800
|
+
function splitAssetKey(key) {
|
|
9801
|
+
const idx = key.indexOf(":");
|
|
9802
|
+
return { type: key.slice(0, idx), destKey: key.slice(idx + 1) };
|
|
9803
|
+
}
|
|
9804
|
+
async function buildLinkPlan(members) {
|
|
9805
|
+
const skipped = [];
|
|
9806
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
9807
|
+
const membersById = new Map(members.map((m) => [m.id, m]));
|
|
9808
|
+
for (const member of members) {
|
|
9809
|
+
if (!await fs44.pathExists(member.absPath)) {
|
|
9810
|
+
skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
|
|
9811
|
+
continue;
|
|
9812
|
+
}
|
|
9813
|
+
if (!await hasLinkableContext(member)) {
|
|
9814
|
+
skipped.push({
|
|
9815
|
+
repo: member.id,
|
|
9816
|
+
reason: "no .claude/skills, .claude/agents, or .claude/commands \u2014 nothing to link"
|
|
9817
|
+
});
|
|
9818
|
+
continue;
|
|
9819
|
+
}
|
|
9820
|
+
const assets = await collectSourceAssets(member);
|
|
9821
|
+
const prefix = repoFolderPrefix(member);
|
|
9822
|
+
for (const asset of assets) {
|
|
9823
|
+
const destKey = `${prefix}--${asset.name}`;
|
|
9824
|
+
const key = assetKey(asset.type, destKey);
|
|
9825
|
+
const list = byKey.get(key) ?? [];
|
|
9826
|
+
list.push({ repo: member.id, asset });
|
|
9827
|
+
byKey.set(key, list);
|
|
9828
|
+
}
|
|
9829
|
+
}
|
|
9830
|
+
const collisions = [];
|
|
9831
|
+
const entries = [];
|
|
9832
|
+
for (const [key, list] of byKey) {
|
|
9833
|
+
const { type, destKey } = splitAssetKey(key);
|
|
9834
|
+
const distinctRepos = [...new Set(list.map((l) => l.repo))];
|
|
9835
|
+
if (distinctRepos.length > 1) {
|
|
9836
|
+
collisions.push({ type, destKey, repos: distinctRepos });
|
|
9837
|
+
continue;
|
|
9838
|
+
}
|
|
9839
|
+
const { repo, asset } = list[0];
|
|
9840
|
+
const member = membersById.get(repo);
|
|
9841
|
+
if (!member) continue;
|
|
9842
|
+
const sourceHash = await hashInstalledPaths(member.absPath, [asset.sourceRelPath], {
|
|
9843
|
+
followSymlinks: false
|
|
9844
|
+
});
|
|
9845
|
+
entries.push({
|
|
9846
|
+
repo,
|
|
9847
|
+
type,
|
|
9848
|
+
name: asset.name,
|
|
9849
|
+
destKey,
|
|
9850
|
+
sourceRelPath: asset.sourceRelPath,
|
|
9851
|
+
sourceAbsPath: path65.join(member.absPath, asset.sourceRelPath),
|
|
9852
|
+
sourceHash
|
|
9853
|
+
});
|
|
9854
|
+
}
|
|
9855
|
+
return { entries, skipped, collisions };
|
|
9856
|
+
}
|
|
9857
|
+
|
|
9858
|
+
// src/workspace/link-context/link.ts
|
|
9859
|
+
function destRelPathFor(type, destKey) {
|
|
9860
|
+
const subdir = `${type}s`;
|
|
9861
|
+
const leaf = type === "skill" ? destKey : `${destKey}.md`;
|
|
9862
|
+
return path66.posix.join(".claude", subdir, leaf);
|
|
9863
|
+
}
|
|
9864
|
+
async function runLinkContext(workspaceRoot, opts = {}) {
|
|
9865
|
+
const write = opts.write ?? false;
|
|
9866
|
+
const say = opts.json ? () => {
|
|
9867
|
+
} : log;
|
|
9868
|
+
const rootInfo = await resolveRoots(workspaceRoot);
|
|
9869
|
+
const members = await readMembers(rootInfo);
|
|
9870
|
+
const plan = await buildLinkPlan(members);
|
|
9871
|
+
if (plan.collisions.length > 0) {
|
|
9872
|
+
const detail = plan.collisions.map((c) => `${c.type} "${c.destKey}" claimed by: ${c.repos.join(", ")}`).join("; ");
|
|
9873
|
+
return {
|
|
9874
|
+
ok: false,
|
|
9875
|
+
error: `Name collision(s) after the repo-folder prefix \u2014 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}`,
|
|
9876
|
+
collisions: plan.collisions
|
|
9877
|
+
};
|
|
9878
|
+
}
|
|
9879
|
+
for (const s of plan.skipped) say(` skip ${s.repo}: ${s.reason}`);
|
|
9880
|
+
const priorManifest = await readManifest2(workspaceRoot);
|
|
9881
|
+
const prior = priorManifest?.linkedContext ?? [];
|
|
9882
|
+
const priorPaths = new Set(prior.map((e) => e.path));
|
|
9883
|
+
const nextEntries = [];
|
|
9884
|
+
const added = [];
|
|
9885
|
+
const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9886
|
+
for (const entry of plan.entries) {
|
|
9887
|
+
const destRel = destRelPathFor(entry.type, entry.destKey);
|
|
9888
|
+
const destAbs = path66.join(workspaceRoot, destRel);
|
|
9889
|
+
if (write) {
|
|
9890
|
+
await fs45.ensureDir(path66.dirname(destAbs));
|
|
9891
|
+
if (await fs45.pathExists(destAbs)) await fs45.remove(destAbs);
|
|
9892
|
+
await fs45.copy(entry.sourceAbsPath, destAbs, {
|
|
9893
|
+
overwrite: true,
|
|
9894
|
+
errorOnExist: false,
|
|
9895
|
+
dereference: false,
|
|
9896
|
+
filter: (s) => {
|
|
9897
|
+
try {
|
|
9898
|
+
return !fs45.lstatSync(s).isSymbolicLink();
|
|
9899
|
+
} catch {
|
|
9900
|
+
return false;
|
|
9901
|
+
}
|
|
9902
|
+
}
|
|
9903
|
+
});
|
|
9904
|
+
}
|
|
9905
|
+
if (!priorPaths.has(destRel)) added.push(destRel);
|
|
9906
|
+
nextEntries.push({
|
|
9907
|
+
repo: entry.repo,
|
|
9908
|
+
type: entry.type,
|
|
9909
|
+
name: entry.name,
|
|
9910
|
+
path: destRel,
|
|
9911
|
+
sourceRelPath: entry.sourceRelPath,
|
|
9912
|
+
sourceHash: entry.sourceHash,
|
|
9913
|
+
linkedAt
|
|
9914
|
+
});
|
|
9915
|
+
}
|
|
9916
|
+
const nextPaths = new Set(nextEntries.map((e) => e.path));
|
|
9917
|
+
const removed = [];
|
|
9918
|
+
for (const old of prior) {
|
|
9919
|
+
if (nextPaths.has(old.path)) continue;
|
|
9920
|
+
removed.push(old.path);
|
|
9921
|
+
if (write) {
|
|
9922
|
+
const abs = path66.join(workspaceRoot, old.path);
|
|
9923
|
+
if (await fs45.pathExists(abs)) await fs45.remove(abs);
|
|
9924
|
+
}
|
|
9925
|
+
}
|
|
9926
|
+
if (write) {
|
|
9927
|
+
await writeLinkedContext(workspaceRoot, nextEntries);
|
|
9928
|
+
await writeLinkContextGitignore(workspaceRoot, false);
|
|
9929
|
+
}
|
|
9930
|
+
return {
|
|
9931
|
+
ok: true,
|
|
9932
|
+
workspaceRoot,
|
|
9933
|
+
dryRun: !write,
|
|
9934
|
+
linked: nextEntries,
|
|
9935
|
+
added,
|
|
9936
|
+
removed,
|
|
9937
|
+
skipped: plan.skipped
|
|
9938
|
+
};
|
|
9939
|
+
}
|
|
9940
|
+
|
|
9941
|
+
// src/commands/workspace/link-context.ts
|
|
9942
|
+
async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
|
|
9943
|
+
const options = { write: opts.write, json: opts.json };
|
|
9944
|
+
const result = await runLinkContext(workspaceRoot, options);
|
|
9945
|
+
if (!result.ok) {
|
|
9946
|
+
if (opts.json) {
|
|
9947
|
+
log(JSON.stringify(result, null, 2));
|
|
9948
|
+
} else {
|
|
9949
|
+
error(result.error);
|
|
9950
|
+
}
|
|
9951
|
+
process.exitCode = 1;
|
|
9952
|
+
return;
|
|
9953
|
+
}
|
|
9954
|
+
if (opts.json) {
|
|
9955
|
+
log(JSON.stringify(result, null, 2));
|
|
9956
|
+
return;
|
|
9957
|
+
}
|
|
9958
|
+
for (const s of result.skipped) warn(` skip ${s.repo}: ${s.reason}`);
|
|
9959
|
+
if (result.dryRun) {
|
|
9960
|
+
log(
|
|
9961
|
+
`[dry-run] Would link ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} to remove). Re-run with --write to apply.`
|
|
9962
|
+
);
|
|
9963
|
+
return;
|
|
9964
|
+
}
|
|
9965
|
+
log(
|
|
9966
|
+
`Linked ${result.linked.length} skill/agent/command copy(ies) into .claude/ (${result.added.length} new, ${result.removed.length} removed).`
|
|
9967
|
+
);
|
|
9968
|
+
log(
|
|
9969
|
+
"Source of truth for each copy stays in its own member repo \u2014 do not edit the copies directly; re-run `haus workspace link-context` after editing a source, or when `haus workspace doctor` flags one stale."
|
|
9970
|
+
);
|
|
9971
|
+
}
|
|
9972
|
+
|
|
7958
9973
|
// src/commands/workspace/setup.ts
|
|
7959
|
-
import { existsSync as
|
|
7960
|
-
import
|
|
9974
|
+
import { existsSync as existsSync8, statSync } from "node:fs";
|
|
9975
|
+
import path68 from "node:path";
|
|
7961
9976
|
|
|
7962
9977
|
// src/claude/write-workspace-claude-md.ts
|
|
7963
|
-
import
|
|
7964
|
-
import
|
|
9978
|
+
import path67 from "node:path";
|
|
9979
|
+
import fs46 from "fs-extra";
|
|
7965
9980
|
function buildWorkspaceImportBlock(client, members) {
|
|
7966
9981
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
7967
9982
|
const body = [
|
|
@@ -7979,11 +9994,11 @@ ${BLOCK_END}`;
|
|
|
7979
9994
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
7980
9995
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
7981
9996
|
const dryRun = opts.dryRun ?? false;
|
|
7982
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
9997
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path67.join(workspaceRoot, "CLAUDE.md");
|
|
7983
9998
|
const say = opts.quiet ? () => {
|
|
7984
9999
|
} : log;
|
|
7985
|
-
const existed = await
|
|
7986
|
-
const prev = existed ? await
|
|
10000
|
+
const existed = await fs46.pathExists(filePath);
|
|
10001
|
+
const prev = existed ? await fs46.readFile(filePath, "utf8") : "";
|
|
7987
10002
|
const next = opts.collision ? `${block}
|
|
7988
10003
|
` : injectHausBlock(prev, block);
|
|
7989
10004
|
const printable = displayPath(workspaceRoot, filePath);
|
|
@@ -8010,20 +10025,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
8010
10025
|
|
|
8011
10026
|
// src/commands/workspace/setup.ts
|
|
8012
10027
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
8013
|
-
let dir =
|
|
10028
|
+
let dir = path68.resolve(start);
|
|
8014
10029
|
for (; ; ) {
|
|
8015
|
-
if (
|
|
8016
|
-
const parent =
|
|
8017
|
-
if (parent === dir) return
|
|
10030
|
+
if (existsSync8(path68.join(dir, WORKSPACE_FILE))) return dir;
|
|
10031
|
+
const parent = path68.dirname(dir);
|
|
10032
|
+
if (parent === dir) return path68.resolve(start);
|
|
8018
10033
|
dir = parent;
|
|
8019
10034
|
}
|
|
8020
10035
|
}
|
|
8021
10036
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
8022
|
-
return
|
|
10037
|
+
return path68.resolve(workspaceRoot, repoPath) === path68.resolve(workspaceRoot);
|
|
8023
10038
|
}
|
|
8024
10039
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
8025
10040
|
const apply = options.write ?? false;
|
|
8026
|
-
const configText = await readText(
|
|
10041
|
+
const configText = await readText(path68.join(workspaceRoot, WORKSPACE_FILE));
|
|
8027
10042
|
if (!configText) {
|
|
8028
10043
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
8029
10044
|
process.exitCode = 1;
|
|
@@ -8050,11 +10065,11 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8050
10065
|
const statuses = [];
|
|
8051
10066
|
const aggregateInputs = [];
|
|
8052
10067
|
for (const repo of repos) {
|
|
8053
|
-
const repoRoot =
|
|
10068
|
+
const repoRoot = path68.resolve(workspaceRoot, repo.path);
|
|
8054
10069
|
say(`
|
|
8055
10070
|
\u2192 ${repo.name} (${repo.path})`);
|
|
8056
10071
|
try {
|
|
8057
|
-
if (!
|
|
10072
|
+
if (!existsSync8(repoRoot) || !statSync(repoRoot).isDirectory()) {
|
|
8058
10073
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
8059
10074
|
}
|
|
8060
10075
|
const res = await runSetupCore(repoRoot, {
|
|
@@ -8110,7 +10125,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8110
10125
|
if (apply && !options.dryRun) {
|
|
8111
10126
|
const statusByName = new Map(statuses.map((s) => [s.name, s]));
|
|
8112
10127
|
const prior = await readManifest2(workspaceRoot);
|
|
8113
|
-
if (!prior &&
|
|
10128
|
+
if (!prior && existsSync8(manifestPath(workspaceRoot))) {
|
|
8114
10129
|
warn(
|
|
8115
10130
|
"Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
|
|
8116
10131
|
);
|
|
@@ -8121,7 +10136,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8121
10136
|
const status = statusByName.get(repo.name);
|
|
8122
10137
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
8123
10138
|
if (status?.status === "ok") {
|
|
8124
|
-
const lock = await checkLock(
|
|
10139
|
+
const lock = await checkLock(path68.resolve(workspaceRoot, repo.path));
|
|
8125
10140
|
manifestRepos.push({
|
|
8126
10141
|
name: repo.name,
|
|
8127
10142
|
path: repo.path,
|
|
@@ -8164,9 +10179,33 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
8164
10179
|
);
|
|
8165
10180
|
}
|
|
8166
10181
|
}
|
|
8167
|
-
const manifest = buildManifest2({
|
|
10182
|
+
const manifest = buildManifest2({
|
|
10183
|
+
client: config.client,
|
|
10184
|
+
repos: manifestRepos,
|
|
10185
|
+
// buildManifest rebuilds the manifest from scratch each run — carry the prior
|
|
10186
|
+
// `linkedContext` section forward so a plain `setup --write` never silently
|
|
10187
|
+
// drops entries `link-context` wrote on an earlier run.
|
|
10188
|
+
linkedContext: prior?.linkedContext
|
|
10189
|
+
});
|
|
8168
10190
|
const manifestFile = await writeWorkspaceManifest(workspaceRoot, manifest);
|
|
8169
10191
|
written.push(manifestFile);
|
|
10192
|
+
try {
|
|
10193
|
+
const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
|
|
10194
|
+
if (linkResult.ok) {
|
|
10195
|
+
written.push(...linkResult.added.map((rel) => path68.join(workspaceRoot, rel)));
|
|
10196
|
+
if (!options.json) {
|
|
10197
|
+
say(
|
|
10198
|
+
`Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
|
|
10199
|
+
);
|
|
10200
|
+
}
|
|
10201
|
+
} else {
|
|
10202
|
+
warn(`Skipping workspace link-context: ${linkResult.error}`);
|
|
10203
|
+
}
|
|
10204
|
+
} catch (err) {
|
|
10205
|
+
warn(
|
|
10206
|
+
`Workspace link-context failed: ${err instanceof Error ? err.message : String(err)} \u2014 run \`haus workspace link-context\` directly to retry.`
|
|
10207
|
+
);
|
|
10208
|
+
}
|
|
8170
10209
|
}
|
|
8171
10210
|
const ok = statuses.filter((s) => s.status === "ok").length;
|
|
8172
10211
|
const failed = statuses.length - ok;
|
|
@@ -8184,9 +10223,9 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
|
|
|
8184
10223
|
}
|
|
8185
10224
|
|
|
8186
10225
|
// src/commands/workspace/undo.ts
|
|
8187
|
-
import { existsSync as
|
|
8188
|
-
import
|
|
8189
|
-
import
|
|
10226
|
+
import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
|
|
10227
|
+
import path69 from "node:path";
|
|
10228
|
+
import fs47 from "fs-extra";
|
|
8190
10229
|
var WORKSPACE_AGGREGATE_FILES = [
|
|
8191
10230
|
"workspace-summary.json",
|
|
8192
10231
|
"dependency-ownership-map.json",
|
|
@@ -8214,11 +10253,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
8214
10253
|
}
|
|
8215
10254
|
let anyRepoFailed = false;
|
|
8216
10255
|
for (const repo of config.repos) {
|
|
8217
|
-
const repoRoot =
|
|
10256
|
+
const repoRoot = path69.resolve(workspaceRoot, repo.path);
|
|
8218
10257
|
log(`
|
|
8219
10258
|
\u2192 ${repo.name} (${repo.path})`);
|
|
8220
10259
|
try {
|
|
8221
|
-
if (!
|
|
10260
|
+
if (!existsSync9(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
8222
10261
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
8223
10262
|
}
|
|
8224
10263
|
await runUndo({ yes: true, root: repoRoot });
|
|
@@ -8227,33 +10266,41 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
8227
10266
|
warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8228
10267
|
}
|
|
8229
10268
|
}
|
|
10269
|
+
const linkedContext = await readLinkedContext(workspaceRoot);
|
|
10270
|
+
for (const entry of linkedContext) {
|
|
10271
|
+
const abs = path69.join(workspaceRoot, entry.path);
|
|
10272
|
+
if (await fs47.pathExists(abs)) {
|
|
10273
|
+
await fs47.remove(abs);
|
|
10274
|
+
log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
10275
|
+
}
|
|
10276
|
+
}
|
|
8230
10277
|
for (const rel of WORKSPACE_AGGREGATE_FILES) {
|
|
8231
10278
|
const abs = hausPath(workspaceRoot, rel);
|
|
8232
|
-
if (await
|
|
8233
|
-
await
|
|
8234
|
-
log(`Removed ${
|
|
10279
|
+
if (await fs47.pathExists(abs)) {
|
|
10280
|
+
await fs47.remove(abs);
|
|
10281
|
+
log(`Removed ${path69.relative(workspaceRoot, abs)}`);
|
|
8235
10282
|
}
|
|
8236
10283
|
}
|
|
8237
10284
|
const manifestFile = manifestPath(workspaceRoot);
|
|
8238
|
-
if (await
|
|
8239
|
-
await
|
|
8240
|
-
log(`Removed ${
|
|
10285
|
+
if (await fs47.pathExists(manifestFile)) {
|
|
10286
|
+
await fs47.remove(manifestFile);
|
|
10287
|
+
log(`Removed ${path69.relative(workspaceRoot, manifestFile)}`);
|
|
8241
10288
|
}
|
|
8242
10289
|
const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
|
|
8243
|
-
if (await
|
|
8244
|
-
await
|
|
8245
|
-
log(`Removed ${
|
|
10290
|
+
if (await fs47.pathExists(workspaceMdPath)) {
|
|
10291
|
+
await fs47.remove(workspaceMdPath);
|
|
10292
|
+
log(`Removed ${path69.relative(workspaceRoot, workspaceMdPath)}`);
|
|
8246
10293
|
} else {
|
|
8247
|
-
const claudeMdPath =
|
|
8248
|
-
if (
|
|
10294
|
+
const claudeMdPath = path69.join(workspaceRoot, "CLAUDE.md");
|
|
10295
|
+
if (existsSync9(claudeMdPath)) {
|
|
8249
10296
|
const prev = await readText(claudeMdPath);
|
|
8250
10297
|
if (prev?.includes(BLOCK_BEGIN)) {
|
|
8251
10298
|
const next = stripHausBlock(prev);
|
|
8252
10299
|
if (next.length === 0) {
|
|
8253
|
-
await
|
|
10300
|
+
await fs47.remove(claudeMdPath);
|
|
8254
10301
|
log("Removed CLAUDE.md (only contained the workspace haus import block).");
|
|
8255
10302
|
} else {
|
|
8256
|
-
await
|
|
10303
|
+
await fs47.writeFile(claudeMdPath, next, "utf8");
|
|
8257
10304
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
8258
10305
|
}
|
|
8259
10306
|
}
|
|
@@ -8269,7 +10316,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
8269
10316
|
}
|
|
8270
10317
|
|
|
8271
10318
|
// src/commands/workspace.ts
|
|
8272
|
-
function
|
|
10319
|
+
function normalizeOnly2(only) {
|
|
8273
10320
|
if (!only) return void 0;
|
|
8274
10321
|
const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
|
|
8275
10322
|
const cleaned = list.map((s) => s.trim()).filter(Boolean);
|
|
@@ -8296,7 +10343,7 @@ relationships: []
|
|
|
8296
10343
|
log("Workspace initialized.");
|
|
8297
10344
|
}
|
|
8298
10345
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
8299
|
-
const configText = await readText(
|
|
10346
|
+
const configText = await readText(path70.join(workspaceRoot, WORKSPACE_FILE));
|
|
8300
10347
|
if (!configText) {
|
|
8301
10348
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
8302
10349
|
process.exitCode = 1;
|
|
@@ -8318,9 +10365,9 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
8318
10365
|
const inputs = [];
|
|
8319
10366
|
const failures = [];
|
|
8320
10367
|
for (const repo of config.repos) {
|
|
8321
|
-
const repoRoot =
|
|
10368
|
+
const repoRoot = path70.resolve(workspaceRoot, repo.path);
|
|
8322
10369
|
try {
|
|
8323
|
-
if (!
|
|
10370
|
+
if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
|
|
8324
10371
|
throw new Error("path is not a directory");
|
|
8325
10372
|
}
|
|
8326
10373
|
const result = await scanProject(repoRoot);
|
|
@@ -8366,7 +10413,7 @@ async function runWorkspace(action, options = {}) {
|
|
|
8366
10413
|
dryRun: options.dryRun,
|
|
8367
10414
|
json: options.json,
|
|
8368
10415
|
continueOnError: options.continueOnError,
|
|
8369
|
-
only:
|
|
10416
|
+
only: normalizeOnly2(options.only)
|
|
8370
10417
|
});
|
|
8371
10418
|
return;
|
|
8372
10419
|
case "doctor":
|
|
@@ -8375,14 +10422,17 @@ async function runWorkspace(action, options = {}) {
|
|
|
8375
10422
|
case "undo":
|
|
8376
10423
|
await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
|
|
8377
10424
|
return;
|
|
10425
|
+
case "link-context":
|
|
10426
|
+
await runWorkspaceLinkContext(workspaceRoot, { write: options.write, json: options.json });
|
|
10427
|
+
return;
|
|
8378
10428
|
}
|
|
8379
10429
|
}
|
|
8380
10430
|
|
|
8381
10431
|
// src/cli.ts
|
|
8382
10432
|
function cliVersion() {
|
|
8383
10433
|
try {
|
|
8384
|
-
const pkgPath =
|
|
8385
|
-
const pkg = JSON.parse(
|
|
10434
|
+
const pkgPath = path71.join(packageRoot(), "package.json");
|
|
10435
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
8386
10436
|
return pkg.version ?? "0.0.0";
|
|
8387
10437
|
} catch {
|
|
8388
10438
|
return "0.0.0";
|
|
@@ -8391,8 +10441,8 @@ function cliVersion() {
|
|
|
8391
10441
|
var program = new Command();
|
|
8392
10442
|
function validateRuntimeNodeVersion() {
|
|
8393
10443
|
try {
|
|
8394
|
-
const pkgPath =
|
|
8395
|
-
const pkg = JSON.parse(
|
|
10444
|
+
const pkgPath = path71.join(packageRoot(), "package.json");
|
|
10445
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
8396
10446
|
const requiredRange = pkg.engines?.node;
|
|
8397
10447
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
8398
10448
|
throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
|
|
@@ -8410,7 +10460,10 @@ program.command("recommend").option("--json").option(
|
|
|
8410
10460
|
"--include <ids...>",
|
|
8411
10461
|
"Force opt-in catalog items into the recommendation as manual selections (space- or comma-separated ids)"
|
|
8412
10462
|
).action(runRecommend);
|
|
8413
|
-
program.command("setup-project").option("--json").
|
|
10463
|
+
program.command("setup-project").option("--json").option(
|
|
10464
|
+
"--force",
|
|
10465
|
+
"Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
|
|
10466
|
+
).action(runSetupProject);
|
|
8414
10467
|
program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
|
|
8415
10468
|
program.command("fetch-refs").description("Fetch and cache llms.txt content from catalog item references").option("--all", "Fetch for all catalog items (default)").option("--id <id>", "Fetch for a single catalog item by id").option("--json", "Emit machine-readable JSON summary to stdout").action(runFetchRefs);
|
|
8416
10469
|
program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
|
|
@@ -8472,6 +10525,39 @@ workspace.command("scan").description("Aggregate a cross-repo summary from a fas
|
|
|
8472
10525
|
workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
|
|
8473
10526
|
workspace.command("doctor").description("Report workspace drift against the manifest").option("--json", "Output the manifest and drift array as JSON").action((opts) => runWorkspace("doctor", opts));
|
|
8474
10527
|
workspace.command("undo").description("Revert haus setup for every configured repo, plus workspace-root artifacts").option("-y, --yes", "Skip confirmation").action((opts) => runWorkspace("undo", opts));
|
|
10528
|
+
workspace.command("link-context").description(
|
|
10529
|
+
"Copy each member repo's skill/agent/command into the workspace root's .claude/, prefixed to avoid collisions"
|
|
10530
|
+
).option("--write", "Persist copies + manifest + gitignore (default previews only)").option("--json", "Output the link result as JSON").action((opts) => runWorkspace("link-context", opts));
|
|
10531
|
+
var worktree = workspace.command("worktree").description(
|
|
10532
|
+
"Materialize workspace member repos as real git worktrees (per docs/plans/workspace-worktree-materialization.md, Task 4)"
|
|
10533
|
+
);
|
|
10534
|
+
worktree.command("add <slug>").description(
|
|
10535
|
+
"Create a workspace worktree plus one member worktree per repo, on a mirrored branch"
|
|
10536
|
+
).option("--branch <name>", "Branch to create/checkout (default: the slug)").option("--only <repos>", "Restrict to comma- or space-separated member ids/folders").option("--no-hydrate", "Skip hydration (CoW clone + install-reconciliation)").option("--dry-run", "Preview without writing or running anything").action(
|
|
10537
|
+
(slug, opts) => runWorktree("add", {
|
|
10538
|
+
slug,
|
|
10539
|
+
branch: opts.branch,
|
|
10540
|
+
only: opts.only,
|
|
10541
|
+
hydrate: opts.hydrate,
|
|
10542
|
+
dryRun: opts.dryRun
|
|
10543
|
+
})
|
|
10544
|
+
);
|
|
10545
|
+
worktree.command("hydrate").description("Re-run CoW clone + install-reconciliation for the current workspace worktree").option("--only <repos>", "Restrict to comma- or space-separated member ids/folders").option("--force", "Re-clone hydration targets even if already present").option(
|
|
10546
|
+
"--materialize",
|
|
10547
|
+
"Also `git worktree add` any member missing entirely (e.g. a worktree created by plain `git worktree add` outside haus) before hydrating it"
|
|
10548
|
+
).option("--dry-run", "Preview without writing or running anything").action(
|
|
10549
|
+
(opts) => runWorktree("hydrate", {
|
|
10550
|
+
only: opts.only,
|
|
10551
|
+
force: opts.force,
|
|
10552
|
+
materialize: opts.materialize,
|
|
10553
|
+
dryRun: opts.dryRun
|
|
10554
|
+
})
|
|
10555
|
+
);
|
|
10556
|
+
worktree.command("list").description("List materialized workspace worktrees and their member status").action(() => runWorktree("list"));
|
|
10557
|
+
worktree.command("remove <slug>").description("Remove a workspace worktree and its member worktrees").option("--force", "Bypass the uncommitted/unpushed-work refusal").option("--dry-run", "Preview without removing anything").action(
|
|
10558
|
+
(slug, opts) => runWorktree("remove", { slug, force: opts.force, dryRun: opts.dryRun })
|
|
10559
|
+
);
|
|
10560
|
+
worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook \u2014 always exits 0").action((opts) => runWorktree("doctor", { fromHook: opts.fromHook }));
|
|
8475
10561
|
program.parseAsync(process.argv).catch((err) => {
|
|
8476
10562
|
const message = err instanceof Error ? err.message : String(err);
|
|
8477
10563
|
error(message);
|