@haus-tech/haus-workflow 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/cli.js +2045 -1179
- package/library/catalog/manifest.json +236 -338
- package/library/catalog/validation-rules.json +20 -0
- package/library/global/skills/haus-workflow/SKILL.md +35 -14
- package/library/global/skills/haus-workflow/references/clone.md +24 -0
- package/package.json +10 -10
package/dist/cli.js
CHANGED
|
@@ -60,15 +60,15 @@ var init_exec = __esm({
|
|
|
60
60
|
|
|
61
61
|
// src/cli.ts
|
|
62
62
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
63
|
-
import
|
|
63
|
+
import path53 from "node:path";
|
|
64
64
|
import { Command } from "commander";
|
|
65
65
|
|
|
66
66
|
// src/commands/apply.ts
|
|
67
|
-
import
|
|
67
|
+
import path22 from "node:path";
|
|
68
68
|
import checkbox from "@inquirer/checkbox";
|
|
69
69
|
|
|
70
70
|
// src/catalog/load-catalog.ts
|
|
71
|
-
import
|
|
71
|
+
import path9 from "node:path";
|
|
72
72
|
|
|
73
73
|
// src/utils/fs.ts
|
|
74
74
|
import crypto from "node:crypto";
|
|
@@ -211,10 +211,9 @@ function packageRoot() {
|
|
|
211
211
|
return path2.resolve(path2.dirname(file), "../..");
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
// src/catalog/remote-catalog.ts
|
|
215
|
-
import
|
|
216
|
-
import
|
|
217
|
-
import fs2 from "fs-extra";
|
|
214
|
+
// src/catalog/remote-catalog/github-tree.ts
|
|
215
|
+
import path6 from "node:path";
|
|
216
|
+
import fs4 from "fs-extra";
|
|
218
217
|
|
|
219
218
|
// src/utils/logger.ts
|
|
220
219
|
var log = (msg, ...args) => {
|
|
@@ -234,275 +233,287 @@ var SUPERPOWERS_SHARED_CATALOG_REL = "skills/superpowers/shared";
|
|
|
234
233
|
var CATALOG_REF = process.env.HAUS_CATALOG_REF;
|
|
235
234
|
var CATALOG_CACHE_SUBDIR = ".claude/haus/catalog-cache";
|
|
236
235
|
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
"rust",
|
|
244
|
-
"java",
|
|
245
|
-
"spring",
|
|
246
|
-
"kotlin",
|
|
247
|
-
"swift",
|
|
248
|
-
"android",
|
|
249
|
-
"flutter",
|
|
250
|
-
"dart",
|
|
251
|
-
"c++",
|
|
252
|
-
"perl",
|
|
253
|
-
"defi",
|
|
254
|
-
"trading"
|
|
255
|
-
],
|
|
256
|
-
requiredSkillFrontmatter: ["description"],
|
|
257
|
-
riskyInstallPatterns: [
|
|
258
|
-
{ source: "\\bnpx\\s+-y\\b", flags: "i" },
|
|
259
|
-
{ source: "\\bnpx\\s+--yes\\b", flags: "i" },
|
|
260
|
-
{ source: "\\byarn\\s+dlx\\b", flags: "i" },
|
|
261
|
-
{ source: "\\bpnpm\\s+dlx\\b", flags: "i" }
|
|
262
|
-
],
|
|
263
|
-
allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
|
|
264
|
-
anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
|
|
265
|
-
npxTsxOnlyExemptSources: ["curated"],
|
|
266
|
-
httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
|
|
267
|
-
placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
|
|
268
|
-
allowedStacks: [
|
|
269
|
-
"haus",
|
|
270
|
-
"security",
|
|
271
|
-
"quality",
|
|
272
|
-
"frontend",
|
|
273
|
-
"backend",
|
|
274
|
-
"testing",
|
|
275
|
-
"review",
|
|
276
|
-
"workflow",
|
|
277
|
-
"reference-pack",
|
|
278
|
-
"core-skill",
|
|
279
|
-
"workflow-skill",
|
|
280
|
-
"stack-skill",
|
|
281
|
-
"review-skill",
|
|
282
|
-
"agent",
|
|
283
|
-
"hook",
|
|
284
|
-
"rule",
|
|
285
|
-
"react",
|
|
286
|
-
"typescript",
|
|
287
|
-
"php",
|
|
288
|
-
"csharp",
|
|
289
|
-
"vendure",
|
|
290
|
-
"vendure3",
|
|
291
|
-
"nestjs",
|
|
292
|
-
"graphql",
|
|
293
|
-
"nx",
|
|
294
|
-
"turbo",
|
|
295
|
-
"nextjs",
|
|
296
|
-
"react19",
|
|
297
|
-
"typescript5",
|
|
298
|
-
"vite8",
|
|
299
|
-
"tanstack-query",
|
|
300
|
-
"tanstack-router",
|
|
301
|
-
"radix",
|
|
302
|
-
"radix-ui",
|
|
303
|
-
"shadcn",
|
|
304
|
-
"shadcn-ui",
|
|
305
|
-
"tailwind",
|
|
306
|
-
"tailwindcss",
|
|
307
|
-
"scss",
|
|
308
|
-
"scss-modules",
|
|
309
|
-
"vue",
|
|
310
|
-
"expressjs",
|
|
311
|
-
"laravel",
|
|
312
|
-
"laravel-nova",
|
|
313
|
-
"wordpress",
|
|
314
|
-
"bedrock",
|
|
315
|
-
"elementor-pro",
|
|
316
|
-
"acf-pro",
|
|
317
|
-
"jetengine",
|
|
318
|
-
"dotnet",
|
|
319
|
-
"oidc",
|
|
320
|
-
"azure-ad",
|
|
321
|
-
"bankid",
|
|
322
|
-
"crypto",
|
|
323
|
-
"postgresql",
|
|
324
|
-
"mariadb",
|
|
325
|
-
"mssql",
|
|
326
|
-
"elasticsearch",
|
|
327
|
-
"yarn4",
|
|
328
|
-
"pnpm89",
|
|
329
|
-
"playwright",
|
|
330
|
-
"testing-library",
|
|
331
|
-
"phpunit",
|
|
332
|
-
"storybook",
|
|
333
|
-
"vitest",
|
|
334
|
-
"jest",
|
|
335
|
-
"redis",
|
|
336
|
-
"sanity",
|
|
337
|
-
"strapi",
|
|
338
|
-
"prisma",
|
|
339
|
-
"cms",
|
|
340
|
-
"database",
|
|
341
|
-
"mysql",
|
|
342
|
-
"saml2",
|
|
343
|
-
"next-auth",
|
|
344
|
-
"auth",
|
|
345
|
-
"expo",
|
|
346
|
-
"react-native",
|
|
347
|
-
"mobile",
|
|
348
|
-
"i18next",
|
|
349
|
-
"i18n",
|
|
350
|
-
"bullmq",
|
|
351
|
-
"queue",
|
|
352
|
-
"sentry",
|
|
353
|
-
"observability",
|
|
354
|
-
"tooling",
|
|
355
|
-
"prettier",
|
|
356
|
-
"eslint",
|
|
357
|
-
"missing-prettier",
|
|
358
|
-
"missing-eslint",
|
|
359
|
-
"docker",
|
|
360
|
-
"pm2",
|
|
361
|
-
"stripe",
|
|
362
|
-
"qliro",
|
|
363
|
-
"supabase",
|
|
364
|
-
"payments",
|
|
365
|
-
"myid",
|
|
366
|
-
"cgi",
|
|
367
|
-
"collection2",
|
|
368
|
-
"deployer-php"
|
|
369
|
-
],
|
|
370
|
-
alwaysAllowedTags: [
|
|
371
|
-
"haus",
|
|
372
|
-
"security",
|
|
373
|
-
"quality",
|
|
374
|
-
"review",
|
|
375
|
-
"workflow",
|
|
376
|
-
"baseline",
|
|
377
|
-
"project-instructions"
|
|
378
|
-
],
|
|
379
|
-
patternTagSuffixes: ["-patterns"]
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
// src/catalog/validation-rules.ts
|
|
383
|
-
var toRegExp = (r) => new RegExp(r.source, r.flags);
|
|
384
|
-
var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
|
|
385
|
-
var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
|
|
386
|
-
var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
|
|
387
|
-
var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
|
|
388
|
-
var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
|
|
389
|
-
var NPX_TSX_ONLY_EXEMPT_SOURCES = validation_rules_default.npxTsxOnlyExemptSources ?? [];
|
|
390
|
-
function isNpxTsxOnlyExempt(_itemType, itemSource) {
|
|
391
|
-
return Boolean(itemSource && NPX_TSX_ONLY_EXEMPT_SOURCES.includes(itemSource));
|
|
236
|
+
// src/catalog/path-safety.ts
|
|
237
|
+
import path3 from "node:path";
|
|
238
|
+
function isSafeCatalogPath(itemPath) {
|
|
239
|
+
if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
240
|
+
const normalized = path3.normalize(itemPath);
|
|
241
|
+
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
392
242
|
}
|
|
393
|
-
function
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
return map;
|
|
243
|
+
function isSafeRelativeFilePath(rel) {
|
|
244
|
+
if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
|
|
245
|
+
if (path3.isAbsolute(rel)) return false;
|
|
246
|
+
const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
|
|
247
|
+
return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
|
|
400
248
|
}
|
|
401
|
-
function
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
249
|
+
function safeJoin(base, itemPath) {
|
|
250
|
+
if (!isSafeCatalogPath(itemPath)) return null;
|
|
251
|
+
const resolved = path3.resolve(base, itemPath);
|
|
252
|
+
return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/catalog/remote-catalog/http.ts
|
|
256
|
+
import path4 from "node:path";
|
|
257
|
+
import fs2 from "fs-extra";
|
|
258
|
+
async function fetchGuarded(url, extract) {
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
warn(`Catalog fetch HTTP ${res.status}: ${url}`);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return await extract(res);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
|
|
268
|
+
return null;
|
|
409
269
|
}
|
|
410
|
-
return void 0;
|
|
411
270
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
var ALLOWED_STACKS = validation_rules_default.allowedStacks;
|
|
415
|
-
var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
|
|
416
|
-
var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
|
|
417
|
-
var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
|
|
418
|
-
function isTagAllowed(tag) {
|
|
419
|
-
const lower = tag.toLowerCase();
|
|
420
|
-
if (ALLOWED_SET.has(lower)) return true;
|
|
421
|
-
return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
271
|
+
async function fetchText(url) {
|
|
272
|
+
return fetchGuarded(url, (res) => res.text());
|
|
422
273
|
}
|
|
423
|
-
function
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
274
|
+
async function fetchBytes(url) {
|
|
275
|
+
return fetchGuarded(url, async (res) => Buffer.from(await res.arrayBuffer()));
|
|
276
|
+
}
|
|
277
|
+
async function writeTextIfChanged(dest, text) {
|
|
278
|
+
if (await fs2.pathExists(dest)) {
|
|
279
|
+
const local = await fs2.readFile(dest, "utf8");
|
|
280
|
+
if (local === text) return "unchanged";
|
|
281
|
+
await fs2.writeFile(dest, text, "utf8");
|
|
282
|
+
return "updated";
|
|
430
283
|
}
|
|
431
|
-
|
|
284
|
+
await fs2.ensureDir(path4.dirname(dest));
|
|
285
|
+
await fs2.writeFile(dest, text, "utf8");
|
|
286
|
+
return "created";
|
|
432
287
|
}
|
|
433
288
|
|
|
434
|
-
// src/catalog/
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
289
|
+
// src/catalog/remote-catalog/ref.ts
|
|
290
|
+
import os2 from "node:os";
|
|
291
|
+
import path5 from "node:path";
|
|
292
|
+
import fs3 from "fs-extra";
|
|
293
|
+
function isTestMode() {
|
|
294
|
+
return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
|
|
438
295
|
}
|
|
439
|
-
function
|
|
440
|
-
|
|
441
|
-
const idx = text.toLowerCase().indexOf(marker.toLowerCase());
|
|
442
|
-
if (idx < 0) return "";
|
|
443
|
-
const tail = text.slice(idx + marker.length);
|
|
444
|
-
const next = tail.search(/\n##\s+/);
|
|
445
|
-
return next < 0 ? tail : tail.slice(0, next);
|
|
296
|
+
function getCacheDir() {
|
|
297
|
+
return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path5.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
|
|
446
298
|
}
|
|
447
|
-
|
|
448
|
-
|
|
299
|
+
var cachedCatalogRef;
|
|
300
|
+
var inFlightCatalogRef;
|
|
301
|
+
function getBundledCatalogRef() {
|
|
302
|
+
try {
|
|
303
|
+
const manifestPath2 = path5.join(packageRoot(), "library/catalog/manifest.json");
|
|
304
|
+
const raw = fs3.readFileSync(manifestPath2, "utf8");
|
|
305
|
+
const data = JSON.parse(raw);
|
|
306
|
+
if (typeof data.version === "string" && data.version) {
|
|
307
|
+
return data.version.startsWith("v") ? data.version : `v${data.version}`;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
return void 0;
|
|
449
312
|
}
|
|
450
|
-
function
|
|
451
|
-
const
|
|
452
|
-
if (!
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const rest = lines[idx].replace(keyRe, "").trim();
|
|
458
|
-
if (!rest) return "";
|
|
459
|
-
if (!isYamlBlockScalarHeader(rest)) {
|
|
460
|
-
return rest.replace(/^["']|["']$/g, "").trim();
|
|
313
|
+
function getResolvedCatalogRef() {
|
|
314
|
+
const resolved = cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
|
|
315
|
+
if (!resolved) {
|
|
316
|
+
warn(
|
|
317
|
+
"Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
|
|
318
|
+
);
|
|
319
|
+
return "main";
|
|
461
320
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
321
|
+
return resolved;
|
|
322
|
+
}
|
|
323
|
+
function isCatalogRefResolved() {
|
|
324
|
+
return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
|
|
325
|
+
}
|
|
326
|
+
var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
|
|
327
|
+
function parseSemverTag(tag) {
|
|
328
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag);
|
|
329
|
+
if (!match) return null;
|
|
330
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
331
|
+
}
|
|
332
|
+
function compareSemver(a, b) {
|
|
333
|
+
for (let i = 0; i < 3; i += 1) {
|
|
334
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
469
335
|
}
|
|
470
|
-
return
|
|
336
|
+
return 0;
|
|
471
337
|
}
|
|
472
|
-
function
|
|
473
|
-
|
|
338
|
+
function githubApiHeaders() {
|
|
339
|
+
const headers = { Accept: "application/vnd.github+json" };
|
|
340
|
+
const auth = process.env["HAUS_GITHUB_TOKEN"] ?? process.env["GITHUB_TOKEN"];
|
|
341
|
+
if (auth) headers["Authorization"] = `Bearer ${auth}`;
|
|
342
|
+
return headers;
|
|
474
343
|
}
|
|
475
|
-
function
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
344
|
+
async function fetchLatestCatalogTag() {
|
|
345
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
346
|
+
try {
|
|
347
|
+
const res = await fetch(CATALOG_TAGS_API_URL, {
|
|
348
|
+
signal: AbortSignal.timeout(5e3),
|
|
349
|
+
headers: githubApiHeaders()
|
|
350
|
+
});
|
|
351
|
+
if (!res.ok) return null;
|
|
352
|
+
const tags = await res.json();
|
|
353
|
+
const valid = tags.map((tag) => {
|
|
354
|
+
const name = typeof tag.name === "string" ? tag.name : "";
|
|
355
|
+
const semver2 = parseSemverTag(name);
|
|
356
|
+
return semver2 ? { name, semver: semver2 } : null;
|
|
357
|
+
}).filter(
|
|
358
|
+
(entry) => entry !== null
|
|
359
|
+
);
|
|
360
|
+
if (valid.length === 0) return null;
|
|
361
|
+
valid.sort((a, b) => compareSemver(b.semver, a.semver));
|
|
362
|
+
return valid[0].name;
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
483
365
|
}
|
|
484
|
-
|
|
366
|
+
}
|
|
367
|
+
async function resolveCatalogRef(opts) {
|
|
368
|
+
const env = opts?.env ?? process.env;
|
|
369
|
+
if (env["HAUS_CATALOG_REF"]) {
|
|
370
|
+
if (env["HAUS_CATALOG_REF"] === "main") {
|
|
371
|
+
warn(
|
|
372
|
+
"HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return env["HAUS_CATALOG_REF"];
|
|
376
|
+
}
|
|
377
|
+
const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
|
|
378
|
+
const tag = await fetchLatest();
|
|
379
|
+
if (tag !== null) return tag;
|
|
380
|
+
const fallback = opts?.fallbackRef;
|
|
381
|
+
if (fallback) {
|
|
382
|
+
warn(
|
|
383
|
+
`Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
384
|
+
);
|
|
385
|
+
return fallback;
|
|
386
|
+
}
|
|
387
|
+
const bundled = getBundledCatalogRef();
|
|
388
|
+
if (bundled) {
|
|
389
|
+
warn(
|
|
390
|
+
`Tag resolution failed \u2014 using bundled snapshot ref ${bundled}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
391
|
+
);
|
|
392
|
+
return bundled;
|
|
393
|
+
}
|
|
394
|
+
warn(
|
|
395
|
+
"Tag resolution failed and no fallback ref is available. Set HAUS_CATALOG_REF explicitly to avoid fetching from main."
|
|
396
|
+
);
|
|
397
|
+
return "main";
|
|
398
|
+
}
|
|
399
|
+
async function remoteBase() {
|
|
400
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
401
|
+
return process.env["HAUS_CATALOG_REMOTE_BASE"];
|
|
402
|
+
}
|
|
403
|
+
if (cachedCatalogRef === void 0) {
|
|
404
|
+
if (!inFlightCatalogRef) {
|
|
405
|
+
inFlightCatalogRef = resolveCatalogRef({ fallbackRef: getBundledCatalogRef() }).then(
|
|
406
|
+
(ref) => {
|
|
407
|
+
cachedCatalogRef = ref;
|
|
408
|
+
return ref;
|
|
409
|
+
}
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
await inFlightCatalogRef;
|
|
413
|
+
}
|
|
414
|
+
return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
|
|
485
415
|
}
|
|
486
416
|
|
|
487
|
-
// src/catalog/
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
for (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return
|
|
417
|
+
// src/catalog/remote-catalog/github-tree.ts
|
|
418
|
+
var cachedBlobPaths;
|
|
419
|
+
var inFlightBlobPaths;
|
|
420
|
+
function sanitizeRelativeFilePaths(files, label) {
|
|
421
|
+
const safe = [];
|
|
422
|
+
for (const rel of files) {
|
|
423
|
+
if (!isSafeRelativeFilePath(rel)) {
|
|
424
|
+
warn(`Rejected unsafe path in ${label}: ${rel}`);
|
|
425
|
+
return null;
|
|
496
426
|
}
|
|
497
|
-
|
|
498
|
-
|
|
427
|
+
safe.push(rel);
|
|
428
|
+
}
|
|
429
|
+
return safe;
|
|
430
|
+
}
|
|
431
|
+
async function listFilesRecursive(dir, base = dir) {
|
|
432
|
+
const out = [];
|
|
433
|
+
let entries;
|
|
434
|
+
try {
|
|
435
|
+
if (!await fs4.pathExists(dir)) return out;
|
|
436
|
+
entries = await fs4.readdir(dir, { withFileTypes: true });
|
|
437
|
+
} catch {
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
const full = path6.join(dir, entry.name);
|
|
442
|
+
if (entry.isDirectory()) {
|
|
443
|
+
out.push(...await listFilesRecursive(full, base));
|
|
444
|
+
} else if (entry.isFile()) {
|
|
445
|
+
out.push(path6.relative(base, full).replace(/\\/g, "/"));
|
|
499
446
|
}
|
|
500
447
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
448
|
+
return out.sort();
|
|
449
|
+
}
|
|
450
|
+
async function listMockPrefixFiles(base, prefix) {
|
|
451
|
+
const text = await fetchText(`${base}/__haus_tree__/${encodeURIComponent(prefix)}`);
|
|
452
|
+
if (text === null) return null;
|
|
453
|
+
try {
|
|
454
|
+
const parsed = JSON.parse(text);
|
|
455
|
+
if (!Array.isArray(parsed) || !parsed.every((e) => typeof e === "string")) return null;
|
|
456
|
+
return parsed;
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
504
459
|
}
|
|
505
|
-
|
|
460
|
+
}
|
|
461
|
+
async function fetchGitHubRecursiveBlobPaths(ref) {
|
|
462
|
+
try {
|
|
463
|
+
const headers = githubApiHeaders();
|
|
464
|
+
const commitRes = await fetch(`${CATALOG_GITHUB_API_URL}/commits/${encodeURIComponent(ref)}`, {
|
|
465
|
+
signal: AbortSignal.timeout(15e3),
|
|
466
|
+
headers
|
|
467
|
+
});
|
|
468
|
+
if (!commitRes.ok) return null;
|
|
469
|
+
const commit = await commitRes.json();
|
|
470
|
+
const treeSha = commit.commit.tree.sha;
|
|
471
|
+
const treeRes = await fetch(`${CATALOG_GITHUB_API_URL}/git/trees/${treeSha}?recursive=1`, {
|
|
472
|
+
signal: AbortSignal.timeout(3e4),
|
|
473
|
+
headers
|
|
474
|
+
});
|
|
475
|
+
if (!treeRes.ok) return null;
|
|
476
|
+
const tree = await treeRes.json();
|
|
477
|
+
if (tree.truncated) {
|
|
478
|
+
warn("Catalog GitHub tree listing was truncated \u2014 refusing partial cache sync");
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
|
|
482
|
+
} catch {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async function fetchCatalogBlobPaths(_base) {
|
|
487
|
+
if (cachedBlobPaths) return cachedBlobPaths;
|
|
488
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
489
|
+
if (!inFlightBlobPaths) {
|
|
490
|
+
inFlightBlobPaths = (async () => {
|
|
491
|
+
const ref = getResolvedCatalogRef();
|
|
492
|
+
const paths = await fetchGitHubRecursiveBlobPaths(ref);
|
|
493
|
+
if (paths) cachedBlobPaths = paths;
|
|
494
|
+
inFlightBlobPaths = void 0;
|
|
495
|
+
return paths;
|
|
496
|
+
})();
|
|
497
|
+
}
|
|
498
|
+
return inFlightBlobPaths;
|
|
499
|
+
}
|
|
500
|
+
async function listFilesUnderCatalogPrefix(prefix, base) {
|
|
501
|
+
const normalized = prefix.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
502
|
+
const prefixSlash = `${normalized}/`;
|
|
503
|
+
let relFiles;
|
|
504
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
505
|
+
relFiles = await listMockPrefixFiles(base, normalized);
|
|
506
|
+
} else {
|
|
507
|
+
const blobs = await fetchCatalogBlobPaths(base);
|
|
508
|
+
if (!blobs) return null;
|
|
509
|
+
relFiles = blobs.filter((p) => p.startsWith(prefixSlash)).map((p) => p.slice(prefixSlash.length)).sort();
|
|
510
|
+
}
|
|
511
|
+
if (!relFiles) return null;
|
|
512
|
+
return sanitizeRelativeFilePaths(relFiles, normalized);
|
|
513
|
+
}
|
|
514
|
+
function _resetBlobPathCacheForNewSync() {
|
|
515
|
+
cachedBlobPaths = void 0;
|
|
516
|
+
inFlightBlobPaths = void 0;
|
|
506
517
|
}
|
|
507
518
|
|
|
508
519
|
// src/catalog/manifest-item-fields.ts
|
|
@@ -534,6 +545,42 @@ function validateReferences(itemId, references) {
|
|
|
534
545
|
}
|
|
535
546
|
return null;
|
|
536
547
|
}
|
|
548
|
+
function validateFormerIds(items) {
|
|
549
|
+
const failures = [];
|
|
550
|
+
const currentIds = new Set(items.map((item) => item.id).filter(Boolean));
|
|
551
|
+
const claimedBy = /* @__PURE__ */ new Map();
|
|
552
|
+
for (const item of items) {
|
|
553
|
+
const itemId = item.id ?? "unknown";
|
|
554
|
+
const formerIds = item.formerIds;
|
|
555
|
+
if (formerIds === void 0) continue;
|
|
556
|
+
if (Array.isArray(formerIds) && formerIds.length === 0) continue;
|
|
557
|
+
if (!isStringArray(formerIds)) {
|
|
558
|
+
failures.push(`${itemId}: formerIds must be a string array`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const seenInItem = /* @__PURE__ */ new Set();
|
|
562
|
+
for (const formerId of formerIds) {
|
|
563
|
+
if (seenInItem.has(formerId)) {
|
|
564
|
+
failures.push(`${itemId}: duplicate formerId "${formerId}"`);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
seenInItem.add(formerId);
|
|
568
|
+
if (formerId === itemId) {
|
|
569
|
+
failures.push(`${itemId}: formerIds must not include own current id`);
|
|
570
|
+
}
|
|
571
|
+
if (currentIds.has(formerId) && formerId !== itemId) {
|
|
572
|
+
failures.push(`${itemId}: formerId "${formerId}" conflicts with another item's current id`);
|
|
573
|
+
}
|
|
574
|
+
const previousOwner = claimedBy.get(formerId);
|
|
575
|
+
if (previousOwner !== void 0) {
|
|
576
|
+
failures.push(`formerId "${formerId}" claimed by both ${previousOwner} and ${itemId}`);
|
|
577
|
+
} else {
|
|
578
|
+
claimedBy.set(formerId, itemId);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return failures;
|
|
583
|
+
}
|
|
537
584
|
function validateCuratedProvenance(item) {
|
|
538
585
|
if (item.source !== "curated") return null;
|
|
539
586
|
if (!isNonEmptyString(item.reviewStatus)) {
|
|
@@ -648,264 +695,352 @@ function parseManifest(json) {
|
|
|
648
695
|
if (typeof item.tokenEstimate !== "number" || !Number.isFinite(item.tokenEstimate)) {
|
|
649
696
|
return { ok: false, error: `${item.id}: tokenEstimate must be a finite number` };
|
|
650
697
|
}
|
|
698
|
+
if (item.formerIds !== void 0) {
|
|
699
|
+
if (Array.isArray(item.formerIds) && item.formerIds.length === 0) {
|
|
700
|
+
} else if (!isStringArray2(item.formerIds)) {
|
|
701
|
+
return { ok: false, error: `${item.id}: formerIds must be a string array` };
|
|
702
|
+
}
|
|
703
|
+
}
|
|
651
704
|
items.push(raw);
|
|
652
705
|
}
|
|
706
|
+
const formerIdFailures = validateFormerIds(items);
|
|
707
|
+
if (formerIdFailures.length > 0) {
|
|
708
|
+
return { ok: false, error: formerIdFailures[0] };
|
|
709
|
+
}
|
|
653
710
|
return { ok: true, manifest: { version: root.version, items } };
|
|
654
711
|
}
|
|
655
712
|
|
|
656
|
-
// src/catalog/remote-catalog.ts
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const raw = fs2.readFileSync(manifestPath2, "utf8");
|
|
669
|
-
const data = JSON.parse(raw);
|
|
670
|
-
if (typeof data.version === "string" && data.version) {
|
|
671
|
-
return data.version.startsWith("v") ? data.version : `v${data.version}`;
|
|
672
|
-
}
|
|
673
|
-
} catch {
|
|
674
|
-
}
|
|
675
|
-
return void 0;
|
|
676
|
-
}
|
|
677
|
-
function getResolvedCatalogRef() {
|
|
678
|
-
const resolved = cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
|
|
679
|
-
if (!resolved) {
|
|
680
|
-
warn(
|
|
681
|
-
"Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
|
|
682
|
-
);
|
|
683
|
-
return "main";
|
|
684
|
-
}
|
|
685
|
-
return resolved;
|
|
686
|
-
}
|
|
687
|
-
function isCatalogRefResolved() {
|
|
688
|
-
return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
|
|
689
|
-
}
|
|
690
|
-
async function resolveCatalogRef(opts) {
|
|
691
|
-
const env = opts?.env ?? process.env;
|
|
692
|
-
if (env["HAUS_CATALOG_REF"]) {
|
|
693
|
-
if (env["HAUS_CATALOG_REF"] === "main") {
|
|
694
|
-
warn(
|
|
695
|
-
"HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
return env["HAUS_CATALOG_REF"];
|
|
699
|
-
}
|
|
700
|
-
const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
|
|
701
|
-
const tag = await fetchLatest();
|
|
702
|
-
if (tag !== null) return tag;
|
|
703
|
-
const fallback = opts?.fallbackRef;
|
|
704
|
-
if (fallback) {
|
|
705
|
-
warn(
|
|
706
|
-
`Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
707
|
-
);
|
|
708
|
-
return fallback;
|
|
709
|
-
}
|
|
710
|
-
const bundled = getBundledCatalogRef();
|
|
711
|
-
if (bundled) {
|
|
712
|
-
warn(
|
|
713
|
-
`Tag resolution failed \u2014 using bundled snapshot ref ${bundled}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
714
|
-
);
|
|
715
|
-
return bundled;
|
|
716
|
-
}
|
|
717
|
-
warn(
|
|
718
|
-
"Tag resolution failed and no fallback ref is available. Set HAUS_CATALOG_REF explicitly to avoid fetching from main."
|
|
719
|
-
);
|
|
720
|
-
return "main";
|
|
721
|
-
}
|
|
722
|
-
async function remoteBase() {
|
|
723
|
-
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
724
|
-
return process.env["HAUS_CATALOG_REMOTE_BASE"];
|
|
725
|
-
}
|
|
726
|
-
if (cachedCatalogRef === void 0) {
|
|
727
|
-
cachedCatalogRef = await resolveCatalogRef({ fallbackRef: getBundledCatalogRef() });
|
|
728
|
-
}
|
|
729
|
-
return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
|
|
730
|
-
}
|
|
731
|
-
async function fetchText(url) {
|
|
732
|
-
try {
|
|
733
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
734
|
-
if (!res.ok) {
|
|
735
|
-
warn(`Catalog fetch HTTP ${res.status}: ${url}`);
|
|
736
|
-
return null;
|
|
737
|
-
}
|
|
738
|
-
return await res.text();
|
|
739
|
-
} catch (e) {
|
|
740
|
-
warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
|
|
741
|
-
return null;
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
async function fetchBytes(url) {
|
|
745
|
-
try {
|
|
746
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
747
|
-
if (!res.ok) {
|
|
748
|
-
warn(`Catalog fetch HTTP ${res.status}: ${url}`);
|
|
749
|
-
return null;
|
|
750
|
-
}
|
|
751
|
-
return Buffer.from(await res.arrayBuffer());
|
|
752
|
-
} catch (e) {
|
|
753
|
-
warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
|
|
754
|
-
return null;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
async function fetchRemoteManifest() {
|
|
758
|
-
const base = await remoteBase();
|
|
759
|
-
const text = await fetchText(`${base}/manifest.json`);
|
|
760
|
-
if (!text) return null;
|
|
761
|
-
const parsed = parseManifest(text);
|
|
762
|
-
if (!parsed.ok) {
|
|
763
|
-
warn(`Remote manifest failed schema validation: ${parsed.error}`);
|
|
764
|
-
return null;
|
|
765
|
-
}
|
|
766
|
-
if (!parsed.manifest.items.length) return null;
|
|
767
|
-
return parsed.manifest;
|
|
768
|
-
}
|
|
769
|
-
async function writeTextIfChanged(dest, text) {
|
|
770
|
-
if (await fs2.pathExists(dest)) {
|
|
771
|
-
const local = await fs2.readFile(dest, "utf8");
|
|
772
|
-
if (local === text) return "unchanged";
|
|
773
|
-
await fs2.writeFile(dest, text, "utf8");
|
|
774
|
-
return "updated";
|
|
775
|
-
}
|
|
776
|
-
await fs2.ensureDir(path3.dirname(dest));
|
|
777
|
-
await fs2.writeFile(dest, text, "utf8");
|
|
778
|
-
return "created";
|
|
713
|
+
// src/catalog/remote-catalog/manifest.ts
|
|
714
|
+
async function fetchRemoteManifest() {
|
|
715
|
+
const base = await remoteBase();
|
|
716
|
+
const text = await fetchText(`${base}/manifest.json`);
|
|
717
|
+
if (!text) return null;
|
|
718
|
+
const parsed = parseManifest(text);
|
|
719
|
+
if (!parsed.ok) {
|
|
720
|
+
warn(`Remote manifest failed schema validation: ${parsed.error}`);
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
if (!parsed.manifest.items.length) return null;
|
|
724
|
+
return parsed.manifest;
|
|
779
725
|
}
|
|
726
|
+
|
|
727
|
+
// src/catalog/remote-catalog/workflow-template.ts
|
|
728
|
+
import path7 from "node:path";
|
|
729
|
+
import fs5 from "fs-extra";
|
|
780
730
|
var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
|
|
781
731
|
async function readWorkflowTemplate(opts = {}) {
|
|
782
|
-
const dest =
|
|
732
|
+
const dest = path7.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
|
|
783
733
|
const base = await remoteBase();
|
|
784
734
|
const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
|
|
785
735
|
if (text === null) {
|
|
786
|
-
if (await
|
|
736
|
+
if (await fs5.pathExists(dest)) return fs5.readFile(dest, "utf8");
|
|
787
737
|
return null;
|
|
788
738
|
}
|
|
789
739
|
if (!opts.dryRun) {
|
|
790
740
|
await writeTextIfChanged(dest, text);
|
|
791
|
-
} else if (await fs2.pathExists(dest)) {
|
|
792
|
-
return fs2.readFile(dest, "utf8");
|
|
793
741
|
}
|
|
794
742
|
return text;
|
|
795
743
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
744
|
+
|
|
745
|
+
// src/catalog/remote-catalog/sync.ts
|
|
746
|
+
import path8 from "node:path";
|
|
747
|
+
import fs6 from "fs-extra";
|
|
748
|
+
|
|
749
|
+
// library/catalog/validation-rules.json
|
|
750
|
+
var validation_rules_default = {
|
|
751
|
+
forbiddenTags: [
|
|
752
|
+
"python",
|
|
753
|
+
"django",
|
|
754
|
+
"go",
|
|
755
|
+
"rust",
|
|
756
|
+
"java",
|
|
757
|
+
"spring",
|
|
758
|
+
"kotlin",
|
|
759
|
+
"swift",
|
|
760
|
+
"android",
|
|
761
|
+
"flutter",
|
|
762
|
+
"dart",
|
|
763
|
+
"c++",
|
|
764
|
+
"perl",
|
|
765
|
+
"defi",
|
|
766
|
+
"trading"
|
|
767
|
+
],
|
|
768
|
+
requiredSkillFrontmatter: ["description"],
|
|
769
|
+
riskyInstallPatterns: [
|
|
770
|
+
{ source: "\\bnpx\\s+-y\\b", flags: "i" },
|
|
771
|
+
{ source: "\\bnpx\\s+--yes\\b", flags: "i" },
|
|
772
|
+
{ source: "\\byarn\\s+dlx\\b", flags: "i" },
|
|
773
|
+
{ source: "\\bpnpm\\s+dlx\\b", flags: "i" }
|
|
774
|
+
],
|
|
775
|
+
allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
|
|
776
|
+
anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
|
|
777
|
+
npxTsxOnlyExemptSources: ["curated"],
|
|
778
|
+
httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
|
|
779
|
+
placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
|
|
780
|
+
allowedStacks: [
|
|
781
|
+
"haus",
|
|
782
|
+
"security",
|
|
783
|
+
"quality",
|
|
784
|
+
"frontend",
|
|
785
|
+
"backend",
|
|
786
|
+
"testing",
|
|
787
|
+
"review",
|
|
788
|
+
"workflow",
|
|
789
|
+
"reference-pack",
|
|
790
|
+
"core-skill",
|
|
791
|
+
"workflow-skill",
|
|
792
|
+
"stack-skill",
|
|
793
|
+
"review-skill",
|
|
794
|
+
"agent",
|
|
795
|
+
"hook",
|
|
796
|
+
"rule",
|
|
797
|
+
"react",
|
|
798
|
+
"typescript",
|
|
799
|
+
"php",
|
|
800
|
+
"csharp",
|
|
801
|
+
"vendure",
|
|
802
|
+
"vendure3",
|
|
803
|
+
"nestjs",
|
|
804
|
+
"graphql",
|
|
805
|
+
"nx",
|
|
806
|
+
"turbo",
|
|
807
|
+
"nextjs",
|
|
808
|
+
"react19",
|
|
809
|
+
"typescript5",
|
|
810
|
+
"vite8",
|
|
811
|
+
"tanstack-query",
|
|
812
|
+
"tanstack-router",
|
|
813
|
+
"radix",
|
|
814
|
+
"radix-ui",
|
|
815
|
+
"shadcn",
|
|
816
|
+
"shadcn-ui",
|
|
817
|
+
"tailwind",
|
|
818
|
+
"tailwindcss",
|
|
819
|
+
"scss",
|
|
820
|
+
"scss-modules",
|
|
821
|
+
"vue",
|
|
822
|
+
"expressjs",
|
|
823
|
+
"laravel",
|
|
824
|
+
"laravel-nova",
|
|
825
|
+
"wordpress",
|
|
826
|
+
"bedrock",
|
|
827
|
+
"elementor-pro",
|
|
828
|
+
"acf-pro",
|
|
829
|
+
"jetengine",
|
|
830
|
+
"dotnet",
|
|
831
|
+
"oidc",
|
|
832
|
+
"azure-ad",
|
|
833
|
+
"bankid",
|
|
834
|
+
"crypto",
|
|
835
|
+
"postgresql",
|
|
836
|
+
"mariadb",
|
|
837
|
+
"mssql",
|
|
838
|
+
"elasticsearch",
|
|
839
|
+
"yarn4",
|
|
840
|
+
"pnpm89",
|
|
841
|
+
"playwright",
|
|
842
|
+
"testing-library",
|
|
843
|
+
"phpunit",
|
|
844
|
+
"storybook",
|
|
845
|
+
"vitest",
|
|
846
|
+
"jest",
|
|
847
|
+
"redis",
|
|
848
|
+
"sanity",
|
|
849
|
+
"strapi",
|
|
850
|
+
"prisma",
|
|
851
|
+
"cms",
|
|
852
|
+
"database",
|
|
853
|
+
"mysql",
|
|
854
|
+
"saml2",
|
|
855
|
+
"next-auth",
|
|
856
|
+
"auth",
|
|
857
|
+
"expo",
|
|
858
|
+
"react-native",
|
|
859
|
+
"mobile",
|
|
860
|
+
"i18next",
|
|
861
|
+
"i18n",
|
|
862
|
+
"bullmq",
|
|
863
|
+
"queue",
|
|
864
|
+
"sentry",
|
|
865
|
+
"observability",
|
|
866
|
+
"tooling",
|
|
867
|
+
"prettier",
|
|
868
|
+
"eslint",
|
|
869
|
+
"missing-prettier",
|
|
870
|
+
"missing-eslint",
|
|
871
|
+
"docker",
|
|
872
|
+
"pm2",
|
|
873
|
+
"stripe",
|
|
874
|
+
"qliro",
|
|
875
|
+
"supabase",
|
|
876
|
+
"payments",
|
|
877
|
+
"myid",
|
|
878
|
+
"cgi",
|
|
879
|
+
"collection2",
|
|
880
|
+
"deployer-php"
|
|
881
|
+
],
|
|
882
|
+
safetyNotesRequiredTags: [
|
|
883
|
+
"stripe",
|
|
884
|
+
"qliro",
|
|
885
|
+
"payments",
|
|
886
|
+
"bankid",
|
|
887
|
+
"myid",
|
|
888
|
+
"oidc",
|
|
889
|
+
"azure-ad",
|
|
890
|
+
"saml2"
|
|
891
|
+
],
|
|
892
|
+
intentsRequiredTags: [
|
|
893
|
+
"stripe",
|
|
894
|
+
"qliro",
|
|
895
|
+
"payments",
|
|
896
|
+
"bankid",
|
|
897
|
+
"myid",
|
|
898
|
+
"oidc",
|
|
899
|
+
"azure-ad",
|
|
900
|
+
"saml2"
|
|
901
|
+
],
|
|
902
|
+
alwaysAllowedTags: [
|
|
903
|
+
"haus",
|
|
904
|
+
"security",
|
|
905
|
+
"quality",
|
|
906
|
+
"review",
|
|
907
|
+
"workflow",
|
|
908
|
+
"baseline",
|
|
909
|
+
"project-instructions"
|
|
910
|
+
],
|
|
911
|
+
patternTagSuffixes: ["-patterns"]
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// src/catalog/validation-rules.ts
|
|
915
|
+
var toRegExp = (r) => new RegExp(r.source, r.flags);
|
|
916
|
+
var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
|
|
917
|
+
var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
|
|
918
|
+
var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
|
|
919
|
+
var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
|
|
920
|
+
var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
|
|
921
|
+
var NPX_TSX_ONLY_EXEMPT_SOURCES = validation_rules_default.npxTsxOnlyExemptSources ?? [];
|
|
922
|
+
function isNpxTsxOnlyExempt(_itemType, itemSource) {
|
|
923
|
+
return Boolean(itemSource && NPX_TSX_ONLY_EXEMPT_SOURCES.includes(itemSource));
|
|
800
924
|
}
|
|
801
|
-
function
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
925
|
+
function buildItemPathSourceMap(items) {
|
|
926
|
+
const map = /* @__PURE__ */ new Map();
|
|
927
|
+
for (const item of items) {
|
|
928
|
+
if (!item.path) continue;
|
|
929
|
+
map.set(String(item.path).replace(/\\/g, "/"), item.source);
|
|
930
|
+
}
|
|
931
|
+
return map;
|
|
806
932
|
}
|
|
807
|
-
function
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
933
|
+
function resolveMarkdownItemSource(relPath, pathSourceMap) {
|
|
934
|
+
const norm = relPath.replace(/\\/g, "/");
|
|
935
|
+
let cursor = norm;
|
|
936
|
+
while (true) {
|
|
937
|
+
if (pathSourceMap.has(cursor)) return pathSourceMap.get(cursor);
|
|
938
|
+
const slash = cursor.lastIndexOf("/");
|
|
939
|
+
if (slash === -1) break;
|
|
940
|
+
cursor = cursor.slice(0, slash);
|
|
941
|
+
}
|
|
942
|
+
return void 0;
|
|
812
943
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
944
|
+
var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
|
|
945
|
+
var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
|
|
946
|
+
var ALLOWED_STACKS = validation_rules_default.allowedStacks;
|
|
947
|
+
var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
|
|
948
|
+
var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
|
|
949
|
+
var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
|
|
950
|
+
function isTagAllowed(tag) {
|
|
951
|
+
const lower = tag.toLowerCase();
|
|
952
|
+
if (ALLOWED_SET.has(lower)) return true;
|
|
953
|
+
return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
954
|
+
}
|
|
955
|
+
function auditDisallowedTags(items) {
|
|
956
|
+
const failures = [];
|
|
957
|
+
for (const item of items) {
|
|
958
|
+
if (!item.id) continue;
|
|
959
|
+
for (const tag of Array.isArray(item.tags) ? item.tags : []) {
|
|
960
|
+
if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
|
|
819
961
|
}
|
|
820
|
-
safe.push(rel);
|
|
821
962
|
}
|
|
822
|
-
return
|
|
963
|
+
return failures;
|
|
823
964
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
965
|
+
|
|
966
|
+
// src/catalog/forbidden-content.ts
|
|
967
|
+
var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
|
|
968
|
+
function escapeRegExp(value) {
|
|
969
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
828
970
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
971
|
+
function extractUseWhenSection(text) {
|
|
972
|
+
const marker = "## Use when";
|
|
973
|
+
const idx = text.toLowerCase().indexOf(marker.toLowerCase());
|
|
974
|
+
if (idx < 0) return "";
|
|
975
|
+
const tail = text.slice(idx + marker.length);
|
|
976
|
+
const next = tail.search(/\n##\s+/);
|
|
977
|
+
return next < 0 ? tail : tail.slice(0, next);
|
|
832
978
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
out.push(path3.relative(base, full).replace(/\\/g, "/"));
|
|
848
|
-
}
|
|
979
|
+
function isYamlBlockScalarHeader(rest) {
|
|
980
|
+
return /^[>|][-+]?(\d+)?(?:\s+#.*)?$/.test(rest);
|
|
981
|
+
}
|
|
982
|
+
function extractFrontmatterValue(text, key) {
|
|
983
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
984
|
+
if (!m) return "";
|
|
985
|
+
const lines = m[1].split(/\r?\n/);
|
|
986
|
+
const keyRe = new RegExp(`^${escapeRegExp(key)}:[ \\t]*`);
|
|
987
|
+
const idx = lines.findIndex((l) => keyRe.test(l));
|
|
988
|
+
if (idx < 0) return "";
|
|
989
|
+
const rest = lines[idx].replace(keyRe, "").trim();
|
|
990
|
+
if (!rest) return "";
|
|
991
|
+
if (!isYamlBlockScalarHeader(rest)) {
|
|
992
|
+
return rest.replace(/^["']|["']$/g, "").trim();
|
|
849
993
|
}
|
|
850
|
-
|
|
994
|
+
const body = [];
|
|
995
|
+
for (let j = idx + 1; j < lines.length; j++) {
|
|
996
|
+
const line2 = lines[j];
|
|
997
|
+
if (/^[a-zA-Z_][\w.-]*:[ \t]/.test(line2)) break;
|
|
998
|
+
if (line2.trim() === "") continue;
|
|
999
|
+
if (/^\s+/.test(line2)) body.push(line2.trimStart());
|
|
1000
|
+
else break;
|
|
1001
|
+
}
|
|
1002
|
+
return body.join(" ").replace(/\s+/g, " ").trim();
|
|
851
1003
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1004
|
+
function extractFrontmatterDescription(text) {
|
|
1005
|
+
return extractFrontmatterValue(text, "description");
|
|
1006
|
+
}
|
|
1007
|
+
function auditForbiddenTagsInText(text, label) {
|
|
1008
|
+
const body = `${extractFrontmatterDescription(text)}
|
|
1009
|
+
${extractUseWhenSection(text)}`;
|
|
1010
|
+
if (!body.trim()) return [];
|
|
1011
|
+
const failures = [];
|
|
1012
|
+
for (const word of PROSE_FORBIDDEN_TAGS) {
|
|
1013
|
+
const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
|
|
1014
|
+
if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
|
|
861
1015
|
}
|
|
1016
|
+
return failures;
|
|
862
1017
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
});
|
|
877
|
-
if (!treeRes.ok) return null;
|
|
878
|
-
const tree = await treeRes.json();
|
|
879
|
-
if (tree.truncated) {
|
|
880
|
-
warn("Catalog GitHub tree listing was truncated \u2014 refusing partial cache sync");
|
|
881
|
-
return null;
|
|
1018
|
+
|
|
1019
|
+
// src/catalog/ingest-catalog.ts
|
|
1020
|
+
function validateCatalogItem(item, content2) {
|
|
1021
|
+
const label = item.id;
|
|
1022
|
+
const checkNonTsxNpx = !isNpxTsxOnlyExempt(item.type, item.source);
|
|
1023
|
+
const lines = content2.split(/\r?\n/);
|
|
1024
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1025
|
+
const line2 = lines[i] ?? "";
|
|
1026
|
+
if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
|
|
1027
|
+
return { ok: false, reason: `${label}: risky install pattern at line ${i + 1}` };
|
|
1028
|
+
}
|
|
1029
|
+
if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
|
|
1030
|
+
return { ok: false, reason: `${label}: disallowed npx at line ${i + 1}` };
|
|
882
1031
|
}
|
|
883
|
-
return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
|
|
884
|
-
} catch {
|
|
885
|
-
return null;
|
|
886
1032
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
891
|
-
const ref = getResolvedCatalogRef();
|
|
892
|
-
const paths = await fetchGitHubRecursiveBlobPaths(ref);
|
|
893
|
-
if (paths) cachedBlobPaths = paths;
|
|
894
|
-
return paths;
|
|
895
|
-
}
|
|
896
|
-
async function listFilesUnderCatalogPrefix(prefix, base) {
|
|
897
|
-
const normalized = prefix.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
898
|
-
const prefixSlash = `${normalized}/`;
|
|
899
|
-
let relFiles;
|
|
900
|
-
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
901
|
-
relFiles = await listMockPrefixFiles(base, normalized);
|
|
902
|
-
} else {
|
|
903
|
-
const blobs = await fetchCatalogBlobPaths(base);
|
|
904
|
-
if (!blobs) return null;
|
|
905
|
-
relFiles = blobs.filter((p) => p.startsWith(prefixSlash)).map((p) => p.slice(prefixSlash.length)).sort();
|
|
1033
|
+
const tagFailures = auditForbiddenTagsInText(content2, label);
|
|
1034
|
+
if (tagFailures.length > 0) {
|
|
1035
|
+
return { ok: false, reason: tagFailures[0] };
|
|
906
1036
|
}
|
|
907
|
-
|
|
908
|
-
|
|
1037
|
+
return { ok: true };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/catalog/remote-catalog/sync.ts
|
|
1041
|
+
var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
|
|
1042
|
+
function isMarkdownPath(rel) {
|
|
1043
|
+
return rel.toLowerCase().endsWith(".md");
|
|
909
1044
|
}
|
|
910
1045
|
async function fetchPrefixFiles(catalogPrefix, relFiles, base, label) {
|
|
911
1046
|
const fetched = [];
|
|
@@ -941,7 +1076,7 @@ function validateMarkdownFiles(item, fetched) {
|
|
|
941
1076
|
return true;
|
|
942
1077
|
}
|
|
943
1078
|
async function directoryMatchesFetched(destDir, fetched) {
|
|
944
|
-
if (!await
|
|
1079
|
+
if (!await fs6.pathExists(destDir)) return false;
|
|
945
1080
|
const existing = await listFilesRecursive(destDir);
|
|
946
1081
|
const relSet = new Set(fetched.map((f) => f.rel));
|
|
947
1082
|
if (existing.length !== fetched.length) return false;
|
|
@@ -949,30 +1084,30 @@ async function directoryMatchesFetched(destDir, fetched) {
|
|
|
949
1084
|
if (!relSet.has(rel)) return false;
|
|
950
1085
|
}
|
|
951
1086
|
for (const file of fetched) {
|
|
952
|
-
const dest =
|
|
953
|
-
if (!await
|
|
1087
|
+
const dest = path8.join(destDir, file.rel);
|
|
1088
|
+
if (!await fs6.pathExists(dest)) return false;
|
|
954
1089
|
if (file.kind === "text") {
|
|
955
|
-
const local = await
|
|
1090
|
+
const local = await fs6.readFile(dest, "utf8");
|
|
956
1091
|
if (local !== file.body) return false;
|
|
957
1092
|
} else {
|
|
958
|
-
const local = await
|
|
1093
|
+
const local = await fs6.readFile(dest);
|
|
959
1094
|
if (!local.equals(file.body)) return false;
|
|
960
1095
|
}
|
|
961
1096
|
}
|
|
962
1097
|
return true;
|
|
963
1098
|
}
|
|
964
1099
|
async function writeFetchedDirectory(destDir, fetched) {
|
|
965
|
-
if (await
|
|
966
|
-
await
|
|
1100
|
+
if (await fs6.pathExists(destDir)) {
|
|
1101
|
+
await fs6.remove(destDir);
|
|
967
1102
|
}
|
|
968
|
-
await
|
|
1103
|
+
await fs6.ensureDir(destDir);
|
|
969
1104
|
for (const file of fetched) {
|
|
970
|
-
const dest =
|
|
971
|
-
await
|
|
1105
|
+
const dest = path8.join(destDir, file.rel);
|
|
1106
|
+
await fs6.ensureDir(path8.dirname(dest));
|
|
972
1107
|
if (file.kind === "text") {
|
|
973
|
-
await
|
|
1108
|
+
await fs6.writeFile(dest, file.body, "utf8");
|
|
974
1109
|
} else {
|
|
975
|
-
await
|
|
1110
|
+
await fs6.writeFile(dest, file.body);
|
|
976
1111
|
}
|
|
977
1112
|
}
|
|
978
1113
|
}
|
|
@@ -1012,7 +1147,7 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
|
|
|
1012
1147
|
}
|
|
1013
1148
|
}
|
|
1014
1149
|
}
|
|
1015
|
-
const existed = await
|
|
1150
|
+
const existed = await fs6.pathExists(destDir);
|
|
1016
1151
|
if (await directoryMatchesFetched(destDir, fetched)) {
|
|
1017
1152
|
return "unchanged";
|
|
1018
1153
|
}
|
|
@@ -1129,7 +1264,7 @@ async function syncOneItem(item, base) {
|
|
|
1129
1264
|
}
|
|
1130
1265
|
}
|
|
1131
1266
|
async function syncRemoteCatalog() {
|
|
1132
|
-
|
|
1267
|
+
_resetBlobPathCacheForNewSync();
|
|
1133
1268
|
const manifest = await fetchRemoteManifest();
|
|
1134
1269
|
if (!manifest) {
|
|
1135
1270
|
warn("Remote catalog fetch failed \u2014 using bundled catalog");
|
|
@@ -1138,9 +1273,9 @@ async function syncRemoteCatalog() {
|
|
|
1138
1273
|
const { version, items } = manifest;
|
|
1139
1274
|
const cacheDir = getCacheDir();
|
|
1140
1275
|
try {
|
|
1141
|
-
await
|
|
1142
|
-
await
|
|
1143
|
-
|
|
1276
|
+
await fs6.ensureDir(cacheDir);
|
|
1277
|
+
await fs6.writeFile(
|
|
1278
|
+
path8.join(cacheDir, "manifest.json"),
|
|
1144
1279
|
`${JSON.stringify({ version, items }, null, 2)}
|
|
1145
1280
|
`,
|
|
1146
1281
|
"utf8"
|
|
@@ -1171,44 +1306,9 @@ async function syncRemoteCatalog() {
|
|
|
1171
1306
|
}
|
|
1172
1307
|
return { newItems, refreshed, unchanged, failed };
|
|
1173
1308
|
}
|
|
1174
|
-
var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
|
|
1175
|
-
function parseSemverTag(tag) {
|
|
1176
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag);
|
|
1177
|
-
if (!match) return null;
|
|
1178
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
1179
|
-
}
|
|
1180
|
-
function compareSemver(a, b) {
|
|
1181
|
-
for (let i = 0; i < 3; i += 1) {
|
|
1182
|
-
if (a[i] !== b[i]) return a[i] - b[i];
|
|
1183
|
-
}
|
|
1184
|
-
return 0;
|
|
1185
|
-
}
|
|
1186
|
-
async function fetchLatestCatalogTag() {
|
|
1187
|
-
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
1188
|
-
try {
|
|
1189
|
-
const res = await fetch(CATALOG_TAGS_API_URL, {
|
|
1190
|
-
signal: AbortSignal.timeout(5e3),
|
|
1191
|
-
headers: githubApiHeaders()
|
|
1192
|
-
});
|
|
1193
|
-
if (!res.ok) return null;
|
|
1194
|
-
const tags = await res.json();
|
|
1195
|
-
const valid = tags.map((tag) => {
|
|
1196
|
-
const name = typeof tag.name === "string" ? tag.name : "";
|
|
1197
|
-
const semver2 = parseSemverTag(name);
|
|
1198
|
-
return semver2 ? { name, semver: semver2 } : null;
|
|
1199
|
-
}).filter(
|
|
1200
|
-
(entry) => entry !== null
|
|
1201
|
-
);
|
|
1202
|
-
if (valid.length === 0) return null;
|
|
1203
|
-
valid.sort((a, b) => compareSemver(b.semver, a.semver));
|
|
1204
|
-
return valid[0].name;
|
|
1205
|
-
} catch {
|
|
1206
|
-
return null;
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
1309
|
async function getCacheManifestAge() {
|
|
1210
1310
|
try {
|
|
1211
|
-
const stat = await
|
|
1311
|
+
const stat = await fs6.stat(path8.join(getCacheDir(), "manifest.json"));
|
|
1212
1312
|
return Date.now() - stat.mtimeMs;
|
|
1213
1313
|
} catch {
|
|
1214
1314
|
return null;
|
|
@@ -1222,30 +1322,30 @@ async function loadCatalogContext(root) {
|
|
|
1222
1322
|
const data2 = await readJson(envPath);
|
|
1223
1323
|
return {
|
|
1224
1324
|
items: data2?.items ?? [],
|
|
1225
|
-
contentRoot:
|
|
1325
|
+
contentRoot: path9.dirname(envPath),
|
|
1226
1326
|
source: "fixture"
|
|
1227
1327
|
};
|
|
1228
1328
|
}
|
|
1229
1329
|
const cacheDir = getCacheDir();
|
|
1230
|
-
const cacheManifestPath =
|
|
1330
|
+
const cacheManifestPath = path9.join(cacheDir, "manifest.json");
|
|
1231
1331
|
const cacheData = await readJson(cacheManifestPath);
|
|
1232
1332
|
if (cacheData?.items?.length) {
|
|
1233
1333
|
return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
|
|
1234
1334
|
}
|
|
1235
|
-
const localManifest =
|
|
1335
|
+
const localManifest = path9.join(root, "library/catalog/manifest.json");
|
|
1236
1336
|
const localData = await readJson(localManifest);
|
|
1237
1337
|
if (localData?.items?.length) {
|
|
1238
1338
|
return {
|
|
1239
1339
|
items: localData.items,
|
|
1240
|
-
contentRoot:
|
|
1340
|
+
contentRoot: path9.dirname(localManifest),
|
|
1241
1341
|
source: "local"
|
|
1242
1342
|
};
|
|
1243
1343
|
}
|
|
1244
|
-
const packageManifest =
|
|
1344
|
+
const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
|
|
1245
1345
|
const data = await readJson(packageManifest);
|
|
1246
1346
|
return {
|
|
1247
1347
|
items: data?.items ?? [],
|
|
1248
|
-
contentRoot:
|
|
1348
|
+
contentRoot: path9.dirname(packageManifest),
|
|
1249
1349
|
source: "bundled"
|
|
1250
1350
|
};
|
|
1251
1351
|
}
|
|
@@ -1254,17 +1354,70 @@ async function loadCatalog(root) {
|
|
|
1254
1354
|
return ctx.items;
|
|
1255
1355
|
}
|
|
1256
1356
|
function catalogItemContentPath(contentRoot, item) {
|
|
1257
|
-
return
|
|
1357
|
+
return path9.join(contentRoot, item.path);
|
|
1258
1358
|
}
|
|
1259
1359
|
|
|
1260
1360
|
// src/claude/write-claude-files.ts
|
|
1261
|
-
import
|
|
1262
|
-
import
|
|
1361
|
+
import path19 from "node:path";
|
|
1362
|
+
import fs18 from "fs-extra";
|
|
1363
|
+
|
|
1364
|
+
// src/catalog/former-ids.ts
|
|
1365
|
+
function normalizeFormerIds(value) {
|
|
1366
|
+
if (value === void 0 || value === null) return [];
|
|
1367
|
+
if (!Array.isArray(value) || value.length === 0) return [];
|
|
1368
|
+
if (!value.every((entry) => typeof entry === "string")) return [];
|
|
1369
|
+
return value.filter((entry) => entry.length > 0);
|
|
1370
|
+
}
|
|
1371
|
+
function assertFormerIdsShape(itemId, formerIds) {
|
|
1372
|
+
if (formerIds === void 0) return;
|
|
1373
|
+
if (Array.isArray(formerIds) && formerIds.length === 0) return;
|
|
1374
|
+
if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
|
|
1375
|
+
throw new Error(`${itemId}: formerIds must be a string array`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
function buildFormerIdMap(items) {
|
|
1379
|
+
const map = /* @__PURE__ */ new Map();
|
|
1380
|
+
const currentIds = new Set(items.map((item) => item.id));
|
|
1381
|
+
for (const item of items) {
|
|
1382
|
+
assertFormerIdsShape(item.id, item.formerIds);
|
|
1383
|
+
const formerIds = item.formerIds ?? [];
|
|
1384
|
+
if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
|
|
1385
|
+
for (const formerId of formerIds) {
|
|
1386
|
+
if (currentIds.has(formerId) && formerId !== item.id) {
|
|
1387
|
+
throw new Error(
|
|
1388
|
+
`formerId ${formerId} on ${item.id} conflicts with another item's current id`
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
const existingOwner = map.get(formerId);
|
|
1392
|
+
if (existingOwner !== void 0) {
|
|
1393
|
+
throw new Error(
|
|
1394
|
+
`duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
map.set(formerId, item.id);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
return map;
|
|
1401
|
+
}
|
|
1402
|
+
function findFormerIdMigrations(lockItems, manifestItems) {
|
|
1403
|
+
const formerIdMap = buildFormerIdMap(manifestItems);
|
|
1404
|
+
return lockItems.flatMap(({ id }) => {
|
|
1405
|
+
const newId = formerIdMap.get(id);
|
|
1406
|
+
return newId === void 0 ? [] : [{ oldId: id, newId }];
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/recommender/orphaned-items.ts
|
|
1411
|
+
function findOrphanedLockEntries(lock, recommendedIds) {
|
|
1412
|
+
return lock.filter(
|
|
1413
|
+
(entry) => typeof entry.id === "string" && !recommendedIds.has(entry.id)
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1263
1416
|
|
|
1264
1417
|
// src/update/hash-installed.ts
|
|
1265
|
-
import
|
|
1418
|
+
import path10 from "node:path";
|
|
1266
1419
|
import fg2 from "fast-glob";
|
|
1267
|
-
import
|
|
1420
|
+
import fs7 from "fs-extra";
|
|
1268
1421
|
|
|
1269
1422
|
// src/claude/haus-managed-header.ts
|
|
1270
1423
|
function parseHausManagedAttrs(line2) {
|
|
@@ -1303,6 +1456,14 @@ function parseHausManagedHeader(line2) {
|
|
|
1303
1456
|
|
|
1304
1457
|
// src/update/hash-installed.ts
|
|
1305
1458
|
var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
|
|
1459
|
+
function digestFileContent(buf) {
|
|
1460
|
+
const asText = buf.toString("utf8");
|
|
1461
|
+
const roundTrip = Buffer.from(asText, "utf8");
|
|
1462
|
+
if (roundTrip.equals(buf)) {
|
|
1463
|
+
return hashText(normaliseLF(asText));
|
|
1464
|
+
}
|
|
1465
|
+
return hashText(buf);
|
|
1466
|
+
}
|
|
1306
1467
|
async function hashInstalledPaths(root, relPaths) {
|
|
1307
1468
|
if (relPaths.length === 0) {
|
|
1308
1469
|
return hashText(EMPTY_LOCK_PATHS_TOKEN);
|
|
@@ -1310,21 +1471,21 @@ async function hashInstalledPaths(root, relPaths) {
|
|
|
1310
1471
|
const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
|
|
1311
1472
|
const fileDigests = [];
|
|
1312
1473
|
for (const rel of normalized) {
|
|
1313
|
-
const abs =
|
|
1314
|
-
if (!await
|
|
1315
|
-
const stat = await
|
|
1474
|
+
const abs = path10.join(root, rel);
|
|
1475
|
+
if (!await fs7.pathExists(abs)) continue;
|
|
1476
|
+
const stat = await fs7.stat(abs);
|
|
1316
1477
|
if (stat.isFile()) {
|
|
1317
|
-
const body = await
|
|
1318
|
-
fileDigests.push({ rel, digest:
|
|
1478
|
+
const body = await fs7.readFile(abs);
|
|
1479
|
+
fileDigests.push({ rel, digest: digestFileContent(body) });
|
|
1319
1480
|
continue;
|
|
1320
1481
|
}
|
|
1321
1482
|
if (!stat.isDirectory()) continue;
|
|
1322
1483
|
const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
|
|
1323
1484
|
for (const sub of inner.sort()) {
|
|
1324
|
-
const relFile =
|
|
1325
|
-
const absFile =
|
|
1326
|
-
const body = await
|
|
1327
|
-
fileDigests.push({ rel: relFile, digest:
|
|
1485
|
+
const relFile = path10.join(rel, sub).replace(/\\/g, "/");
|
|
1486
|
+
const absFile = path10.join(abs, sub);
|
|
1487
|
+
const body = await fs7.readFile(absFile);
|
|
1488
|
+
fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
|
|
1328
1489
|
}
|
|
1329
1490
|
}
|
|
1330
1491
|
if (fileDigests.length === 0) {
|
|
@@ -1335,7 +1496,7 @@ async function hashInstalledPaths(root, relPaths) {
|
|
|
1335
1496
|
}
|
|
1336
1497
|
|
|
1337
1498
|
// src/claude/managed-write.ts
|
|
1338
|
-
import
|
|
1499
|
+
import fs8 from "fs-extra";
|
|
1339
1500
|
|
|
1340
1501
|
// src/utils/diff.ts
|
|
1341
1502
|
import { createTwoFilesPatch } from "diff";
|
|
@@ -1361,10 +1522,11 @@ function summarizeDiff(diffText) {
|
|
|
1361
1522
|
|
|
1362
1523
|
// src/claude/managed-write.ts
|
|
1363
1524
|
async function writeManagedText(root, filePath, nextText, dryRun) {
|
|
1364
|
-
const
|
|
1525
|
+
const existed = await fs8.pathExists(filePath);
|
|
1526
|
+
const prev = existed ? await fs8.readFile(filePath, "utf8") : "";
|
|
1365
1527
|
const printable = displayPath(root, filePath);
|
|
1366
1528
|
if (dryRun) {
|
|
1367
|
-
if (!
|
|
1529
|
+
if (!existed) {
|
|
1368
1530
|
log(createUnifiedDiff(printable, "", nextText));
|
|
1369
1531
|
} else if (hasTextChanged(prev, nextText)) {
|
|
1370
1532
|
log(createUnifiedDiff(printable, prev, nextText));
|
|
@@ -1373,12 +1535,14 @@ async function writeManagedText(root, filePath, nextText, dryRun) {
|
|
|
1373
1535
|
}
|
|
1374
1536
|
return;
|
|
1375
1537
|
}
|
|
1376
|
-
if (hasTextChanged(prev, nextText)
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1538
|
+
if (!existed || hasTextChanged(prev, nextText)) {
|
|
1539
|
+
if (existed) {
|
|
1540
|
+
const diffText = createUnifiedDiff(printable, prev, nextText);
|
|
1541
|
+
const summary = summarizeDiff(diffText);
|
|
1542
|
+
log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
1543
|
+
}
|
|
1544
|
+
await writeText(filePath, nextText);
|
|
1380
1545
|
}
|
|
1381
|
-
await writeText(filePath, nextText);
|
|
1382
1546
|
}
|
|
1383
1547
|
async function writeManagedJson(root, filePath, value, dryRun) {
|
|
1384
1548
|
const nextText = `${JSON.stringify(value, null, 2)}
|
|
@@ -1393,18 +1557,18 @@ function buildAllowRules() {
|
|
|
1393
1557
|
}
|
|
1394
1558
|
|
|
1395
1559
|
// src/install/settings-merge.ts
|
|
1396
|
-
import
|
|
1397
|
-
import
|
|
1560
|
+
import path12 from "node:path";
|
|
1561
|
+
import fs9 from "fs-extra";
|
|
1398
1562
|
|
|
1399
1563
|
// src/install/manifest.ts
|
|
1400
1564
|
import os3 from "node:os";
|
|
1401
|
-
import
|
|
1565
|
+
import path11 from "node:path";
|
|
1402
1566
|
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
1403
1567
|
function globalClaudeDir() {
|
|
1404
|
-
return
|
|
1568
|
+
return path11.join(os3.homedir(), ".claude");
|
|
1405
1569
|
}
|
|
1406
1570
|
function hausManifestPath() {
|
|
1407
|
-
return
|
|
1571
|
+
return path11.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
1408
1572
|
}
|
|
1409
1573
|
async function readManifest() {
|
|
1410
1574
|
return readJson(hausManifestPath());
|
|
@@ -1424,7 +1588,7 @@ function buildManifest(source, files, hooks) {
|
|
|
1424
1588
|
|
|
1425
1589
|
// src/install/settings-merge.ts
|
|
1426
1590
|
function settingsJsonPath() {
|
|
1427
|
-
return
|
|
1591
|
+
return path12.join(globalClaudeDir(), "settings.json");
|
|
1428
1592
|
}
|
|
1429
1593
|
async function readSettings() {
|
|
1430
1594
|
const filePath = settingsJsonPath();
|
|
@@ -1539,77 +1703,62 @@ function reconcileManagedRules(existing, prevTracked, newRules) {
|
|
|
1539
1703
|
const removed = existing.filter((rule) => !newSet.has(rule));
|
|
1540
1704
|
return { rules: [...userRules, ...tracked], tracked, added, removed };
|
|
1541
1705
|
}
|
|
1706
|
+
function createRuleTier(permKey, trackedKey) {
|
|
1707
|
+
function merge(settings, rules) {
|
|
1708
|
+
const existing = settings.permissions?.[permKey] ?? [];
|
|
1709
|
+
const tracked = settings._haus?.[trackedKey] ?? [];
|
|
1710
|
+
const {
|
|
1711
|
+
rules: nextRules,
|
|
1712
|
+
tracked: nextTracked,
|
|
1713
|
+
added
|
|
1714
|
+
} = reconcileManagedRules(existing, tracked, rules);
|
|
1715
|
+
const updated = { ...settings };
|
|
1716
|
+
updated.permissions = { ...settings.permissions ?? {}, [permKey]: nextRules };
|
|
1717
|
+
updated._haus = {
|
|
1718
|
+
hooks: settings._haus?.hooks ?? [],
|
|
1719
|
+
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
1720
|
+
...permKey !== "deny" && settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
1721
|
+
...permKey !== "allow" && settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
|
|
1722
|
+
...permKey !== "ask" && settings._haus?.askRules ? { askRules: settings._haus.askRules } : {},
|
|
1723
|
+
[trackedKey]: nextTracked
|
|
1724
|
+
};
|
|
1725
|
+
return { settings: updated, addedRules: added };
|
|
1726
|
+
}
|
|
1727
|
+
function strip(settings) {
|
|
1728
|
+
const prevHaus = settings._haus;
|
|
1729
|
+
const ownedRules = prevHaus?.[trackedKey];
|
|
1730
|
+
if (!ownedRules || ownedRules.length === 0) return settings;
|
|
1731
|
+
const ownedSet = new Set(ownedRules);
|
|
1732
|
+
const updated = { ...settings };
|
|
1733
|
+
const remaining = (settings.permissions?.[permKey] ?? []).filter((rule) => !ownedSet.has(rule));
|
|
1734
|
+
const permissions = { ...settings.permissions ?? {} };
|
|
1735
|
+
if (remaining.length > 0) permissions[permKey] = remaining;
|
|
1736
|
+
else delete permissions[permKey];
|
|
1737
|
+
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
1738
|
+
else delete updated.permissions;
|
|
1739
|
+
const haus = { ...prevHaus };
|
|
1740
|
+
delete haus[trackedKey];
|
|
1741
|
+
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0 || (haus.askRules?.length ?? 0) > 0;
|
|
1742
|
+
if (stillTracking) updated._haus = haus;
|
|
1743
|
+
else delete updated._haus;
|
|
1744
|
+
return updated;
|
|
1745
|
+
}
|
|
1746
|
+
return { merge, strip };
|
|
1747
|
+
}
|
|
1748
|
+
var denyTier = createRuleTier("deny", "denyRules");
|
|
1749
|
+
var allowTier = createRuleTier("allow", "allowRules");
|
|
1750
|
+
var askTier = createRuleTier("ask", "askRules");
|
|
1542
1751
|
function mergeDenyRules(settings, rules) {
|
|
1543
|
-
|
|
1544
|
-
const trackedDeny = settings._haus?.denyRules ?? [];
|
|
1545
|
-
const { rules: deny, tracked, added } = reconcileManagedRules(existingDeny, trackedDeny, rules);
|
|
1546
|
-
const updated = { ...settings };
|
|
1547
|
-
updated.permissions = {
|
|
1548
|
-
...settings.permissions ?? {},
|
|
1549
|
-
deny
|
|
1550
|
-
};
|
|
1551
|
-
updated._haus = {
|
|
1552
|
-
hooks: settings._haus?.hooks ?? [],
|
|
1553
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
1554
|
-
denyRules: tracked,
|
|
1555
|
-
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
|
|
1556
|
-
...settings._haus?.askRules ? { askRules: settings._haus.askRules } : {}
|
|
1557
|
-
};
|
|
1558
|
-
return { settings: updated, addedRules: added };
|
|
1752
|
+
return denyTier.merge(settings, rules);
|
|
1559
1753
|
}
|
|
1560
1754
|
function mergeAllowRules(settings, rules) {
|
|
1561
|
-
|
|
1562
|
-
const trackedAllow = settings._haus?.allowRules ?? [];
|
|
1563
|
-
const { rules: allow, tracked, added } = reconcileManagedRules(existingAllow, trackedAllow, rules);
|
|
1564
|
-
const updated = { ...settings };
|
|
1565
|
-
updated.permissions = {
|
|
1566
|
-
...settings.permissions ?? {},
|
|
1567
|
-
allow
|
|
1568
|
-
};
|
|
1569
|
-
updated._haus = {
|
|
1570
|
-
hooks: settings._haus?.hooks ?? [],
|
|
1571
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
1572
|
-
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
1573
|
-
allowRules: tracked,
|
|
1574
|
-
...settings._haus?.askRules ? { askRules: settings._haus.askRules } : {}
|
|
1575
|
-
};
|
|
1576
|
-
return { settings: updated, addedRules: added };
|
|
1755
|
+
return allowTier.merge(settings, rules);
|
|
1577
1756
|
}
|
|
1578
1757
|
function stripHausAllow(settings) {
|
|
1579
|
-
|
|
1580
|
-
if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
|
|
1581
|
-
const ownedSet = new Set(prevHaus.allowRules);
|
|
1582
|
-
const updated = { ...settings };
|
|
1583
|
-
const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
|
|
1584
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
1585
|
-
if (remainingAllow.length > 0) permissions.allow = remainingAllow;
|
|
1586
|
-
else delete permissions.allow;
|
|
1587
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
1588
|
-
else delete updated.permissions;
|
|
1589
|
-
const haus = { ...prevHaus };
|
|
1590
|
-
delete haus.allowRules;
|
|
1591
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0 || (haus.askRules?.length ?? 0) > 0;
|
|
1592
|
-
if (stillTracking) updated._haus = haus;
|
|
1593
|
-
else delete updated._haus;
|
|
1594
|
-
return updated;
|
|
1758
|
+
return allowTier.strip(settings);
|
|
1595
1759
|
}
|
|
1596
1760
|
function stripHausDeny(settings) {
|
|
1597
|
-
|
|
1598
|
-
if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
|
|
1599
|
-
const ownedSet = new Set(prevHaus.denyRules);
|
|
1600
|
-
const updated = { ...settings };
|
|
1601
|
-
const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
|
|
1602
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
1603
|
-
if (remainingDeny.length > 0) permissions.deny = remainingDeny;
|
|
1604
|
-
else delete permissions.deny;
|
|
1605
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
1606
|
-
else delete updated.permissions;
|
|
1607
|
-
const haus = { ...prevHaus };
|
|
1608
|
-
delete haus.denyRules;
|
|
1609
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0 || (haus.askRules?.length ?? 0) > 0;
|
|
1610
|
-
if (stillTracking) updated._haus = haus;
|
|
1611
|
-
else delete updated._haus;
|
|
1612
|
-
return updated;
|
|
1761
|
+
return denyTier.strip(settings);
|
|
1613
1762
|
}
|
|
1614
1763
|
function stripHausHooks(settings) {
|
|
1615
1764
|
if (!settings._haus) return settings;
|
|
@@ -1631,45 +1780,15 @@ function stripHausHooks(settings) {
|
|
|
1631
1780
|
return rest;
|
|
1632
1781
|
}
|
|
1633
1782
|
function mergeAskRules(settings, rules) {
|
|
1634
|
-
|
|
1635
|
-
const trackedAsk = settings._haus?.askRules ?? [];
|
|
1636
|
-
const { rules: ask2, tracked, added } = reconcileManagedRules(existingAsk, trackedAsk, rules);
|
|
1637
|
-
const updated = { ...settings };
|
|
1638
|
-
updated.permissions = {
|
|
1639
|
-
...settings.permissions ?? {},
|
|
1640
|
-
ask: ask2
|
|
1641
|
-
};
|
|
1642
|
-
updated._haus = {
|
|
1643
|
-
hooks: settings._haus?.hooks ?? [],
|
|
1644
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
1645
|
-
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
1646
|
-
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
|
|
1647
|
-
askRules: tracked
|
|
1648
|
-
};
|
|
1649
|
-
return { settings: updated, addedRules: added };
|
|
1783
|
+
return askTier.merge(settings, rules);
|
|
1650
1784
|
}
|
|
1651
1785
|
function stripHausAsk(settings) {
|
|
1652
|
-
|
|
1653
|
-
if (!prevHaus?.askRules || prevHaus.askRules.length === 0) return settings;
|
|
1654
|
-
const ownedSet = new Set(prevHaus.askRules);
|
|
1655
|
-
const updated = { ...settings };
|
|
1656
|
-
const remainingAsk = (settings.permissions?.ask ?? []).filter((rule) => !ownedSet.has(rule));
|
|
1657
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
1658
|
-
if (remainingAsk.length > 0) permissions.ask = remainingAsk;
|
|
1659
|
-
else delete permissions.ask;
|
|
1660
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
1661
|
-
else delete updated.permissions;
|
|
1662
|
-
const haus = { ...prevHaus };
|
|
1663
|
-
delete haus.askRules;
|
|
1664
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
|
|
1665
|
-
if (stillTracking) updated._haus = haus;
|
|
1666
|
-
else delete updated._haus;
|
|
1667
|
-
return updated;
|
|
1786
|
+
return askTier.strip(settings);
|
|
1668
1787
|
}
|
|
1669
1788
|
async function loadHooksFragment(fragmentPath) {
|
|
1670
1789
|
let raw;
|
|
1671
1790
|
try {
|
|
1672
|
-
raw = await
|
|
1791
|
+
raw = await fs9.readJson(fragmentPath);
|
|
1673
1792
|
} catch {
|
|
1674
1793
|
return [];
|
|
1675
1794
|
}
|
|
@@ -1784,8 +1903,8 @@ function buildDenyRules() {
|
|
|
1784
1903
|
for (const command of DENY_COMMANDS) {
|
|
1785
1904
|
rules.push(`Bash(${command}:*)`);
|
|
1786
1905
|
}
|
|
1787
|
-
for (const
|
|
1788
|
-
const pattern = DENY_DIRS.has(
|
|
1906
|
+
for (const path54 of DENY_PATHS) {
|
|
1907
|
+
const pattern = DENY_DIRS.has(path54) ? `${path54}/**` : path54;
|
|
1789
1908
|
for (const tool of FILE_TOOLS) {
|
|
1790
1909
|
rules.push(`${tool}(${pattern})`);
|
|
1791
1910
|
}
|
|
@@ -1857,9 +1976,9 @@ async function applyProjectSettingsMerge(root) {
|
|
|
1857
1976
|
}
|
|
1858
1977
|
|
|
1859
1978
|
// src/claude/superpowers-install.ts
|
|
1860
|
-
import
|
|
1979
|
+
import path13 from "node:path";
|
|
1861
1980
|
import fg3 from "fast-glob";
|
|
1862
|
-
import
|
|
1981
|
+
import fs10 from "fs-extra";
|
|
1863
1982
|
var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
|
|
1864
1983
|
var PATH_REWRITES = [
|
|
1865
1984
|
["skills/shared/", ".claude/skills/shared/"]
|
|
@@ -1874,41 +1993,41 @@ function rewriteSuperpowersMarkdown(text) {
|
|
|
1874
1993
|
async function rewriteMarkdownTree(dir) {
|
|
1875
1994
|
const files = await fg3("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
|
|
1876
1995
|
for (const rel of files) {
|
|
1877
|
-
const abs =
|
|
1878
|
-
const text = await
|
|
1996
|
+
const abs = path13.join(dir, rel);
|
|
1997
|
+
const text = await fs10.readFile(abs, "utf8");
|
|
1879
1998
|
const rewritten = rewriteSuperpowersMarkdown(text);
|
|
1880
1999
|
if (rewritten !== text) {
|
|
1881
|
-
await
|
|
2000
|
+
await fs10.writeFile(abs, rewritten, "utf8");
|
|
1882
2001
|
}
|
|
1883
2002
|
}
|
|
1884
2003
|
}
|
|
1885
2004
|
async function installCatalogSkill(sourcePath, destination, opts) {
|
|
1886
2005
|
if (opts.dryRun) return;
|
|
1887
|
-
await
|
|
1888
|
-
if (await
|
|
1889
|
-
await
|
|
2006
|
+
await fs10.ensureDir(path13.dirname(destination));
|
|
2007
|
+
if (await fs10.pathExists(destination)) {
|
|
2008
|
+
await fs10.remove(destination);
|
|
1890
2009
|
}
|
|
1891
|
-
await
|
|
2010
|
+
await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
1892
2011
|
if (opts.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID) {
|
|
1893
2012
|
await rewriteMarkdownTree(destination);
|
|
1894
2013
|
}
|
|
1895
2014
|
}
|
|
1896
2015
|
async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
|
|
1897
|
-
const source =
|
|
1898
|
-
if (!await
|
|
2016
|
+
const source = path13.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
|
|
2017
|
+
if (!await fs10.pathExists(source)) return null;
|
|
1899
2018
|
const destination = claudePath(projectRoot, "skills", "shared");
|
|
1900
|
-
if (dryRun) return
|
|
1901
|
-
await
|
|
1902
|
-
if (await
|
|
1903
|
-
await
|
|
2019
|
+
if (dryRun) return path13.relative(projectRoot, destination);
|
|
2020
|
+
await fs10.ensureDir(path13.dirname(destination));
|
|
2021
|
+
if (await fs10.pathExists(destination)) {
|
|
2022
|
+
await fs10.remove(destination);
|
|
1904
2023
|
}
|
|
1905
|
-
await
|
|
2024
|
+
await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
|
|
1906
2025
|
await rewriteMarkdownTree(destination);
|
|
1907
|
-
return
|
|
2026
|
+
return path13.relative(projectRoot, destination);
|
|
1908
2027
|
}
|
|
1909
2028
|
|
|
1910
2029
|
// src/claude/verify-hooks-contract.ts
|
|
1911
|
-
import
|
|
2030
|
+
import fs11 from "fs-extra";
|
|
1912
2031
|
|
|
1913
2032
|
// src/claude/load-hooks.ts
|
|
1914
2033
|
var CANONICAL_HOOKS = {
|
|
@@ -1988,7 +2107,7 @@ async function assertPostApplySettingsHausContract(root) {
|
|
|
1988
2107
|
}
|
|
1989
2108
|
async function verifyProjectSettingsHooksContract(root) {
|
|
1990
2109
|
const settingsPath = claudePath(root, "settings.json");
|
|
1991
|
-
if (!await
|
|
2110
|
+
if (!await fs11.pathExists(settingsPath)) {
|
|
1992
2111
|
return {
|
|
1993
2112
|
ok: true,
|
|
1994
2113
|
skipped: true,
|
|
@@ -2015,8 +2134,8 @@ async function verifyProjectSettingsHooksContract(root) {
|
|
|
2015
2134
|
}
|
|
2016
2135
|
|
|
2017
2136
|
// src/claude/write-decisions-seed.ts
|
|
2018
|
-
import
|
|
2019
|
-
import
|
|
2137
|
+
import path14 from "node:path";
|
|
2138
|
+
import fs12 from "fs-extra";
|
|
2020
2139
|
|
|
2021
2140
|
// library/catalog/decisions-triggers.json
|
|
2022
2141
|
var decisions_triggers_default = {
|
|
@@ -2059,24 +2178,36 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
|
|
|
2059
2178
|
| --- | ----- | -------------- | ------ |
|
|
2060
2179
|
`;
|
|
2061
2180
|
async function writeDecisionsSeed(root, dryRun) {
|
|
2062
|
-
const decisionsDir =
|
|
2063
|
-
const readmePath =
|
|
2064
|
-
if (await
|
|
2065
|
-
if (!dryRun) await
|
|
2181
|
+
const decisionsDir = path14.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
2182
|
+
const readmePath = path14.join(decisionsDir, "README.md");
|
|
2183
|
+
if (await fs12.pathExists(readmePath)) return null;
|
|
2184
|
+
if (!dryRun) await fs12.ensureDir(decisionsDir);
|
|
2066
2185
|
await writeManagedText(root, readmePath, SEED_README, dryRun);
|
|
2067
2186
|
return readmePath;
|
|
2068
2187
|
}
|
|
2069
2188
|
|
|
2070
2189
|
// src/claude/write-prettierignore.ts
|
|
2071
|
-
import
|
|
2072
|
-
import
|
|
2190
|
+
import path15 from "node:path";
|
|
2191
|
+
import fs13 from "fs-extra";
|
|
2073
2192
|
var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
2074
2193
|
var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
|
|
2075
|
-
var IGNORED_PATHS = [".haus-workflow/"];
|
|
2194
|
+
var IGNORED_PATHS = [".haus-workflow/", ".claude/"];
|
|
2195
|
+
function prettierIgnoreProtects(lines, dir) {
|
|
2196
|
+
const bare = dir.replace(/\/+$/, "");
|
|
2197
|
+
const equivalents = /* @__PURE__ */ new Set([
|
|
2198
|
+
bare,
|
|
2199
|
+
`${bare}/`,
|
|
2200
|
+
`/${bare}`,
|
|
2201
|
+
`/${bare}/`,
|
|
2202
|
+
`${bare}/**`,
|
|
2203
|
+
`/${bare}/**`
|
|
2204
|
+
]);
|
|
2205
|
+
return lines.some((line2) => equivalents.has(line2.trim()));
|
|
2206
|
+
}
|
|
2076
2207
|
function buildPrettierIgnoreBlock() {
|
|
2077
2208
|
return [
|
|
2078
2209
|
PRETTIERIGNORE_BEGIN,
|
|
2079
|
-
"# haus-owned files \u2014 do not reformat (keeps tamper
|
|
2210
|
+
"# haus-owned files \u2014 do not reformat (keeps tamper/lock hashes stable)",
|
|
2080
2211
|
...IGNORED_PATHS,
|
|
2081
2212
|
PRETTIERIGNORE_END
|
|
2082
2213
|
].join("\n");
|
|
@@ -2125,16 +2256,16 @@ ${block}
|
|
|
2125
2256
|
`;
|
|
2126
2257
|
}
|
|
2127
2258
|
async function writePrettierIgnore(root, dryRun) {
|
|
2128
|
-
const filePath =
|
|
2129
|
-
const prev = await
|
|
2259
|
+
const filePath = path15.join(root, ".prettierignore");
|
|
2260
|
+
const prev = await fs13.pathExists(filePath) ? await fs13.readFile(filePath, "utf8") : "";
|
|
2130
2261
|
const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
|
|
2131
2262
|
await writeManagedText(root, filePath, next, dryRun);
|
|
2132
2263
|
return filePath;
|
|
2133
2264
|
}
|
|
2134
2265
|
|
|
2135
2266
|
// src/claude/write-root-claude-md.ts
|
|
2136
|
-
import
|
|
2137
|
-
import
|
|
2267
|
+
import path16 from "node:path";
|
|
2268
|
+
import fs14 from "fs-extra";
|
|
2138
2269
|
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
2139
2270
|
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
2140
2271
|
var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
|
|
@@ -2165,12 +2296,20 @@ function findImportBlockRange(content2) {
|
|
|
2165
2296
|
}
|
|
2166
2297
|
function stripHausBlock(existing) {
|
|
2167
2298
|
const range = findImportBlockRange(existing);
|
|
2168
|
-
if (
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2299
|
+
if (range) {
|
|
2300
|
+
const before = existing.slice(0, range.beginStart);
|
|
2301
|
+
const after = existing.slice(range.endEnd);
|
|
2302
|
+
const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
2303
|
+
return merged.length > 0 ? `${merged}
|
|
2304
|
+
` : "";
|
|
2305
|
+
}
|
|
2306
|
+
const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
|
|
2307
|
+
if (loneBegin) {
|
|
2308
|
+
const before = existing.slice(0, loneBegin.start).trimEnd();
|
|
2309
|
+
return before.length > 0 ? `${before}
|
|
2173
2310
|
` : "";
|
|
2311
|
+
}
|
|
2312
|
+
return existing;
|
|
2174
2313
|
}
|
|
2175
2314
|
function injectHausBlock(existing, block) {
|
|
2176
2315
|
const range = findImportBlockRange(existing);
|
|
@@ -2200,21 +2339,21 @@ ${block}
|
|
|
2200
2339
|
`;
|
|
2201
2340
|
}
|
|
2202
2341
|
async function writeRootClaudeMd(root, dryRun) {
|
|
2203
|
-
const filePath =
|
|
2342
|
+
const filePath = path16.join(root, "CLAUDE.md");
|
|
2204
2343
|
const block = buildImportBlock();
|
|
2205
|
-
const prev = await
|
|
2344
|
+
const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
|
|
2206
2345
|
const next = injectHausBlock(prev, block);
|
|
2207
2346
|
await writeManagedText(root, filePath, next, dryRun);
|
|
2208
2347
|
return filePath;
|
|
2209
2348
|
}
|
|
2210
2349
|
|
|
2211
2350
|
// src/claude/write-workflow-config.ts
|
|
2212
|
-
import
|
|
2213
|
-
import
|
|
2351
|
+
import path18 from "node:path";
|
|
2352
|
+
import fs16 from "fs-extra";
|
|
2214
2353
|
|
|
2215
2354
|
// src/claude/derive-workflow-config.ts
|
|
2216
|
-
import
|
|
2217
|
-
import
|
|
2355
|
+
import path17 from "node:path";
|
|
2356
|
+
import fs15 from "fs-extra";
|
|
2218
2357
|
function binCmd(pm, bin, args) {
|
|
2219
2358
|
const tail = args ? ` ${args}` : "";
|
|
2220
2359
|
if (pm === "yarn") return `yarn ${bin}${tail}`;
|
|
@@ -2223,7 +2362,7 @@ function binCmd(pm, bin, args) {
|
|
|
2223
2362
|
}
|
|
2224
2363
|
async function deriveWorkflowConfig(root, ctx) {
|
|
2225
2364
|
const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
|
|
2226
|
-
const pkg = await readJson(
|
|
2365
|
+
const pkg = await readJson(path17.join(root, "package.json"));
|
|
2227
2366
|
const scripts = pkg?.scripts ?? {};
|
|
2228
2367
|
const deps = new Set(ctx.dependencies);
|
|
2229
2368
|
const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
|
|
@@ -2233,7 +2372,7 @@ async function deriveWorkflowConfig(root, ctx) {
|
|
|
2233
2372
|
return null;
|
|
2234
2373
|
};
|
|
2235
2374
|
const hasDep = (name) => deps.has(name);
|
|
2236
|
-
const exists = (rel) =>
|
|
2375
|
+
const exists = (rel) => fs15.pathExistsSync(path17.join(root, rel));
|
|
2237
2376
|
const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
|
|
2238
2377
|
const hasCypress = hasDep("cypress");
|
|
2239
2378
|
const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
|
|
@@ -2301,7 +2440,7 @@ var FALLBACK_CONTEXT = {
|
|
|
2301
2440
|
async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
2302
2441
|
const destPath = hausPath(root, "workflow-config.md");
|
|
2303
2442
|
const printable = displayPath(root, destPath);
|
|
2304
|
-
const exists = await
|
|
2443
|
+
const exists = await fs16.pathExists(destPath);
|
|
2305
2444
|
if (exists && !opts.refill) {
|
|
2306
2445
|
if (dryRun) log(printable + ": exists (project-owned, skipping)");
|
|
2307
2446
|
return null;
|
|
@@ -2309,11 +2448,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2309
2448
|
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
2310
2449
|
...FALLBACK_CONTEXT,
|
|
2311
2450
|
root,
|
|
2312
|
-
repoName:
|
|
2451
|
+
repoName: path18.basename(root)
|
|
2313
2452
|
};
|
|
2314
2453
|
const values = await deriveWorkflowConfig(root, ctx);
|
|
2315
2454
|
if (exists) {
|
|
2316
|
-
const current = await
|
|
2455
|
+
const current = await fs16.readFile(destPath, "utf8");
|
|
2317
2456
|
const refilled = refillContent(current, values);
|
|
2318
2457
|
if (refilled === current) {
|
|
2319
2458
|
if (dryRun) log(printable + ": no blank fields to refill");
|
|
@@ -2335,7 +2474,23 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2335
2474
|
}
|
|
2336
2475
|
|
|
2337
2476
|
// src/claude/write-workflow.ts
|
|
2338
|
-
import
|
|
2477
|
+
import fs17 from "fs-extra";
|
|
2478
|
+
|
|
2479
|
+
// src/claude/managed-tamper.ts
|
|
2480
|
+
function checkManagedTamper(body, storedHash, templateContent) {
|
|
2481
|
+
const bodyHash = hashText(normaliseLF(body));
|
|
2482
|
+
if (storedHash) {
|
|
2483
|
+
if (bodyHash !== storedHash) return { status: "tampered" };
|
|
2484
|
+
if (templateContent === null) return { status: "ok" };
|
|
2485
|
+
const currentTemplateHash2 = hashText(normaliseLF(templateContent));
|
|
2486
|
+
return storedHash !== currentTemplateHash2 ? { status: "stale" } : { status: "ok" };
|
|
2487
|
+
}
|
|
2488
|
+
if (templateContent === null) return { status: "legacy-ok" };
|
|
2489
|
+
const currentTemplateHash = hashText(normaliseLF(templateContent));
|
|
2490
|
+
return bodyHash === currentTemplateHash ? { status: "legacy-ok" } : { status: "legacy-diverged" };
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// src/claude/write-workflow.ts
|
|
2339
2494
|
var STABLE_ID = "template.workflow";
|
|
2340
2495
|
function makeWorkflowHeader(pkgVersion, contentHash) {
|
|
2341
2496
|
return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
|
|
@@ -2354,8 +2509,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
|
|
|
2354
2509
|
${templateContent}`;
|
|
2355
2510
|
const destPath = hausPath(root, "WORKFLOW.md");
|
|
2356
2511
|
const printable = displayPath(root, destPath);
|
|
2357
|
-
if (await
|
|
2358
|
-
const existing = await
|
|
2512
|
+
if (await fs17.pathExists(destPath)) {
|
|
2513
|
+
const existing = await fs17.readFile(destPath, "utf8");
|
|
2359
2514
|
const firstLine = existing.split("\n")[0] ?? "";
|
|
2360
2515
|
const parsed = parseHausManagedHeader(firstLine);
|
|
2361
2516
|
if (!parsed) {
|
|
@@ -2373,32 +2528,25 @@ ${templateContent}`;
|
|
|
2373
2528
|
return null;
|
|
2374
2529
|
}
|
|
2375
2530
|
const existingContent = existing.slice(firstLine.length + 1);
|
|
2376
|
-
const
|
|
2377
|
-
if (
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
if (!bodyMatchesTemplate && !force) {
|
|
2385
|
-
const migratedHeader = makeWorkflowHeader(
|
|
2386
|
-
pkgVersion,
|
|
2387
|
-
hashText(normaliseLF(existingContent))
|
|
2388
|
-
);
|
|
2389
|
-
const migrated = `${migratedHeader}
|
|
2531
|
+
const verdict = checkManagedTamper(existingContent, parsed.hash, templateContent);
|
|
2532
|
+
if (verdict.status === "tampered" && !force) {
|
|
2533
|
+
warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
|
|
2534
|
+
return null;
|
|
2535
|
+
}
|
|
2536
|
+
if (verdict.status === "legacy-diverged" && !force) {
|
|
2537
|
+
const migratedHeader = makeWorkflowHeader(pkgVersion, hashText(normaliseLF(existingContent)));
|
|
2538
|
+
const migrated = `${migratedHeader}
|
|
2390
2539
|
${existingContent}`;
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
}
|
|
2397
|
-
} else if (dryRun) {
|
|
2398
|
-
log(`${printable}: unchanged`);
|
|
2540
|
+
if (hasTextChanged(existing, migrated)) {
|
|
2541
|
+
if (dryRun) {
|
|
2542
|
+
log(`${printable}: migrating legacy hash header (body preserved)`);
|
|
2543
|
+
} else {
|
|
2544
|
+
await writeText(destPath, migrated);
|
|
2399
2545
|
}
|
|
2400
|
-
|
|
2546
|
+
} else if (dryRun) {
|
|
2547
|
+
log(`${printable}: unchanged`);
|
|
2401
2548
|
}
|
|
2549
|
+
return destPath;
|
|
2402
2550
|
}
|
|
2403
2551
|
if (!hasTextChanged(existing, next)) {
|
|
2404
2552
|
if (dryRun) log(`${printable}: unchanged`);
|
|
@@ -2406,7 +2554,7 @@ ${existingContent}`;
|
|
|
2406
2554
|
}
|
|
2407
2555
|
}
|
|
2408
2556
|
if (dryRun) {
|
|
2409
|
-
const prev = await
|
|
2557
|
+
const prev = await fs17.pathExists(destPath) ? await fs17.readFile(destPath, "utf8") : "";
|
|
2410
2558
|
if (!prev) {
|
|
2411
2559
|
log(createUnifiedDiff(printable, "", next));
|
|
2412
2560
|
} else {
|
|
@@ -2428,6 +2576,19 @@ function targetDirForType(type) {
|
|
|
2428
2576
|
if (type === "skill") return "skills";
|
|
2429
2577
|
return null;
|
|
2430
2578
|
}
|
|
2579
|
+
async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say) {
|
|
2580
|
+
const target = claudePath(root, ...relPathSegments);
|
|
2581
|
+
if (!await fs18.pathExists(target)) return;
|
|
2582
|
+
const content2 = await fs18.readFile(target, "utf8");
|
|
2583
|
+
if (content2 !== stub && content2 !== `${stub}
|
|
2584
|
+
` && content2 !== `${stub}\r
|
|
2585
|
+
`) return;
|
|
2586
|
+
if (dryRun) {
|
|
2587
|
+
say(`[dry-run] would remove stale ${displayPath(root, target)}`);
|
|
2588
|
+
} else {
|
|
2589
|
+
await fs18.remove(target);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2431
2592
|
async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
2432
2593
|
const say = opts.quiet ? () => {
|
|
2433
2594
|
} : log;
|
|
@@ -2441,7 +2602,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2441
2602
|
estimatedTokenReductionPct: 0
|
|
2442
2603
|
};
|
|
2443
2604
|
const pkgRoot = packageRoot();
|
|
2444
|
-
const hausVersion2 = (await readJson(
|
|
2605
|
+
const hausVersion2 = (await readJson(path19.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
2445
2606
|
const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
|
|
2446
2607
|
const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
|
|
2447
2608
|
const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
|
|
@@ -2465,34 +2626,20 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2465
2626
|
await applyProjectSettingsMerge(root);
|
|
2466
2627
|
await assertPostApplySettingsHausContract(root);
|
|
2467
2628
|
}
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
const legacyDoctorPath = claudePath(root, "commands", "haus-doctor.md");
|
|
2483
|
-
if (await fs14.pathExists(legacyDoctorPath)) {
|
|
2484
|
-
const content2 = await fs14.readFile(legacyDoctorPath, "utf8");
|
|
2485
|
-
const stub = "Run `haus doctor`.";
|
|
2486
|
-
if (content2 === stub || content2 === `${stub}
|
|
2487
|
-
` || content2 === `${stub}\r
|
|
2488
|
-
`) {
|
|
2489
|
-
if (dryRun) {
|
|
2490
|
-
say(`[dry-run] would remove stale ${displayPath(root, legacyDoctorPath)}`);
|
|
2491
|
-
} else {
|
|
2492
|
-
await fs14.remove(legacyDoctorPath);
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2629
|
+
await removeLegacyManagedStub(
|
|
2630
|
+
root,
|
|
2631
|
+
["commands", "haus-review.md"],
|
|
2632
|
+
'Run `haus context --task "code review"` then review diff.',
|
|
2633
|
+
dryRun,
|
|
2634
|
+
say
|
|
2635
|
+
);
|
|
2636
|
+
await removeLegacyManagedStub(
|
|
2637
|
+
root,
|
|
2638
|
+
["commands", "haus-doctor.md"],
|
|
2639
|
+
"Run `haus doctor`.",
|
|
2640
|
+
dryRun,
|
|
2641
|
+
say
|
|
2642
|
+
);
|
|
2496
2643
|
await writeManagedText(
|
|
2497
2644
|
root,
|
|
2498
2645
|
claudePath(root, "rules", "haus.md"),
|
|
@@ -2516,20 +2663,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2516
2663
|
].join("\n"),
|
|
2517
2664
|
dryRun
|
|
2518
2665
|
);
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
if (dryRun) {
|
|
2527
|
-
say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
|
|
2528
|
-
} else {
|
|
2529
|
-
await fs14.remove(legacySecurityPath);
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2666
|
+
await removeLegacyManagedStub(
|
|
2667
|
+
root,
|
|
2668
|
+
["rules", "security.md"],
|
|
2669
|
+
"- Never read secrets.\n- Block dangerous shell commands.",
|
|
2670
|
+
dryRun,
|
|
2671
|
+
say
|
|
2672
|
+
);
|
|
2533
2673
|
const LEGACY_PRUNED_ARTIFACTS = [
|
|
2534
2674
|
"config.json",
|
|
2535
2675
|
"selected-context.json",
|
|
@@ -2541,19 +2681,68 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2541
2681
|
];
|
|
2542
2682
|
for (const rel of LEGACY_PRUNED_ARTIFACTS) {
|
|
2543
2683
|
const legacyPath = hausPath(root, rel);
|
|
2544
|
-
if (await
|
|
2684
|
+
if (await fs18.pathExists(legacyPath)) {
|
|
2545
2685
|
if (dryRun) {
|
|
2546
2686
|
say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
|
|
2547
2687
|
} else {
|
|
2548
|
-
await
|
|
2688
|
+
await fs18.remove(legacyPath);
|
|
2549
2689
|
}
|
|
2550
2690
|
}
|
|
2551
2691
|
}
|
|
2552
2692
|
const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
|
|
2553
2693
|
const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
|
|
2694
|
+
const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
2695
|
+
const allMigrations = findFormerIdMigrations(
|
|
2696
|
+
prevLock.filter((entry) => Boolean(entry.id)),
|
|
2697
|
+
manifestItems
|
|
2698
|
+
);
|
|
2699
|
+
const migrations = allMigrations.filter(
|
|
2700
|
+
(migration) => selectedIds === void 0 || selectedIds.includes(migration.oldId) || selectedIds.includes(migration.newId)
|
|
2701
|
+
);
|
|
2702
|
+
const migrationByOldId = new Map(
|
|
2703
|
+
migrations.map((migration) => [migration.oldId, migration.newId])
|
|
2704
|
+
);
|
|
2705
|
+
const cleanupManifestById = new Map(manifestById);
|
|
2706
|
+
for (const migration of allMigrations) {
|
|
2707
|
+
const currentItem = manifestById.get(migration.newId);
|
|
2708
|
+
if (currentItem) cleanupManifestById.set(migration.oldId, currentItem);
|
|
2709
|
+
}
|
|
2710
|
+
for (const migration of migrations) {
|
|
2711
|
+
if (dryRun) {
|
|
2712
|
+
say(`[dry-run] would migrate ${migration.oldId} \u2192 ${migration.newId} (upstream rename)`);
|
|
2713
|
+
} else {
|
|
2714
|
+
warn(`migrated ${migration.oldId} \u2192 ${migration.newId} (upstream rename)`);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
await cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, opts.quiet);
|
|
2554
2718
|
const installedPathsByItem = /* @__PURE__ */ new Map();
|
|
2555
2719
|
const installedIds = /* @__PURE__ */ new Set();
|
|
2556
|
-
const
|
|
2720
|
+
const selectedCatalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
|
|
2721
|
+
const catalogItemsById = /* @__PURE__ */ new Map();
|
|
2722
|
+
for (const recommended of selectedCatalogItems) {
|
|
2723
|
+
const id = migrationByOldId.get(recommended.id) ?? recommended.id;
|
|
2724
|
+
const manifestItem = manifestById.get(id);
|
|
2725
|
+
catalogItemsById.set(id, {
|
|
2726
|
+
...recommended,
|
|
2727
|
+
id,
|
|
2728
|
+
type: manifestItem?.type ?? recommended.type
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
for (const migration of migrations) {
|
|
2732
|
+
if (catalogItemsById.has(migration.newId)) continue;
|
|
2733
|
+
const lockItem = prevLock.find((entry) => entry.id === migration.oldId);
|
|
2734
|
+
const manifestItem = manifestById.get(migration.newId);
|
|
2735
|
+
if (!lockItem || !manifestItem) continue;
|
|
2736
|
+
catalogItemsById.set(migration.newId, {
|
|
2737
|
+
id: migration.newId,
|
|
2738
|
+
type: manifestItem.type,
|
|
2739
|
+
reason: "migrated from former catalog id",
|
|
2740
|
+
reasons: [{ code: "former-id", message: `Renamed from ${migration.oldId}` }],
|
|
2741
|
+
selectionMode: "manual",
|
|
2742
|
+
install: true
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
const catalogItems = [...catalogItemsById.values()];
|
|
2557
2746
|
let curatedReviewStatusSkips = 0;
|
|
2558
2747
|
let superpowersSharedInstalled = false;
|
|
2559
2748
|
for (const item of catalogItems) {
|
|
@@ -2587,21 +2776,21 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2587
2776
|
);
|
|
2588
2777
|
continue;
|
|
2589
2778
|
}
|
|
2590
|
-
const destination = claudePath(root, target,
|
|
2591
|
-
if (await
|
|
2779
|
+
const destination = claudePath(root, target, path19.basename(sourcePath));
|
|
2780
|
+
if (await fs18.pathExists(sourcePath)) {
|
|
2592
2781
|
if (dryRun) {
|
|
2593
|
-
const exists = await
|
|
2782
|
+
const exists = await fs18.pathExists(destination);
|
|
2594
2783
|
say(
|
|
2595
2784
|
`${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
|
|
2596
2785
|
);
|
|
2597
2786
|
} else if (item.type === "skill") {
|
|
2598
|
-
const skillFiles = (await
|
|
2787
|
+
const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
|
|
2599
2788
|
(f) => typeof f === "string" && f.endsWith(".md")
|
|
2600
2789
|
);
|
|
2601
2790
|
let skillValid = true;
|
|
2602
2791
|
for (const relFile of skillFiles) {
|
|
2603
|
-
const mdPath =
|
|
2604
|
-
const mdContent = await
|
|
2792
|
+
const mdPath = path19.join(sourcePath, relFile);
|
|
2793
|
+
const mdContent = await fs18.readFile(mdPath, "utf8");
|
|
2605
2794
|
const mdValidation = validateCatalogItem(
|
|
2606
2795
|
{
|
|
2607
2796
|
id: manifestItem.id,
|
|
@@ -2625,7 +2814,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2625
2814
|
dryRun: false
|
|
2626
2815
|
});
|
|
2627
2816
|
} else {
|
|
2628
|
-
const fileContent = await
|
|
2817
|
+
const fileContent = await fs18.readFile(sourcePath, "utf8");
|
|
2629
2818
|
const validation = validateCatalogItem(
|
|
2630
2819
|
{
|
|
2631
2820
|
id: manifestItem.id,
|
|
@@ -2639,17 +2828,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2639
2828
|
warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
|
|
2640
2829
|
continue;
|
|
2641
2830
|
}
|
|
2642
|
-
await
|
|
2643
|
-
await
|
|
2831
|
+
await fs18.ensureDir(path19.dirname(destination));
|
|
2832
|
+
await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
2644
2833
|
}
|
|
2645
2834
|
files.push(destination);
|
|
2646
|
-
const relPaths = [
|
|
2835
|
+
const relPaths = [path19.relative(root, destination)];
|
|
2647
2836
|
if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
|
|
2648
2837
|
const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
|
|
2649
2838
|
if (sharedRel) {
|
|
2650
2839
|
superpowersSharedInstalled = true;
|
|
2651
2840
|
relPaths.push(sharedRel);
|
|
2652
|
-
files.push(
|
|
2841
|
+
files.push(path19.join(root, sharedRel));
|
|
2653
2842
|
}
|
|
2654
2843
|
}
|
|
2655
2844
|
const current = installedPathsByItem.get(item.id) ?? [];
|
|
@@ -2666,12 +2855,21 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2666
2855
|
`${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
|
|
2667
2856
|
);
|
|
2668
2857
|
}
|
|
2669
|
-
await cleanupStaleCatalogItems(root,
|
|
2858
|
+
await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
|
|
2859
|
+
if (opts.prune) {
|
|
2860
|
+
const notOrphaned = new Set(rec.recommended.map((r) => r.id));
|
|
2861
|
+
for (const migration of allMigrations) notOrphaned.add(migration.oldId);
|
|
2862
|
+
for (const entry of prevLock) {
|
|
2863
|
+
if (!entry.id) continue;
|
|
2864
|
+
const manifestItem = manifestById.get(entry.id);
|
|
2865
|
+
if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
|
|
2866
|
+
}
|
|
2867
|
+
await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
|
|
2868
|
+
}
|
|
2670
2869
|
if (dryRun) return [...new Set(files)];
|
|
2671
2870
|
const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
|
|
2672
|
-
const prevLock = await readJson(hausPath(root, "haus.lock.json"));
|
|
2673
2871
|
const prevRefById = new Map(
|
|
2674
|
-
|
|
2872
|
+
prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
|
|
2675
2873
|
);
|
|
2676
2874
|
const lockCatalogRef = (itemId) => isCatalogRefResolved() ? getResolvedCatalogRef() : prevRefById.get(itemId) ?? getResolvedCatalogRef();
|
|
2677
2875
|
const lock = await Promise.all(
|
|
@@ -2703,6 +2901,44 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2703
2901
|
await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
|
|
2704
2902
|
return [...new Set(files)];
|
|
2705
2903
|
}
|
|
2904
|
+
async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
|
|
2905
|
+
const say = quiet ? () => {
|
|
2906
|
+
} : log;
|
|
2907
|
+
for (const migration of migrations) {
|
|
2908
|
+
const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
|
|
2909
|
+
if (!entry) continue;
|
|
2910
|
+
const relPaths = entry.paths ?? [];
|
|
2911
|
+
const existing = [];
|
|
2912
|
+
for (const rel of relPaths) {
|
|
2913
|
+
if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
|
|
2914
|
+
}
|
|
2915
|
+
if (existing.length === 0) continue;
|
|
2916
|
+
if (entry.hash === void 0) {
|
|
2917
|
+
warn(
|
|
2918
|
+
`Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.join(", ")}`
|
|
2919
|
+
);
|
|
2920
|
+
continue;
|
|
2921
|
+
}
|
|
2922
|
+
const currentHash = await hashInstalledPaths(root, existing);
|
|
2923
|
+
if (currentHash !== entry.hash) {
|
|
2924
|
+
warn(
|
|
2925
|
+
`Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.join(", ")}`
|
|
2926
|
+
);
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2929
|
+
for (const rel of existing) {
|
|
2930
|
+
const abs = path19.join(root, rel);
|
|
2931
|
+
if (dryRun) {
|
|
2932
|
+
say(
|
|
2933
|
+
`[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
|
|
2934
|
+
);
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
await fs18.remove(abs);
|
|
2938
|
+
await pruneEmptyDir(path19.dirname(abs));
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2706
2942
|
async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
2707
2943
|
const say = quiet ? () => {
|
|
2708
2944
|
} : log;
|
|
@@ -2718,7 +2954,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
|
2718
2954
|
if (relPaths.length === 0) continue;
|
|
2719
2955
|
const existing = [];
|
|
2720
2956
|
for (const rel of relPaths) {
|
|
2721
|
-
if (await
|
|
2957
|
+
if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
|
|
2722
2958
|
}
|
|
2723
2959
|
if (existing.length === 0) continue;
|
|
2724
2960
|
const pruneReason = deprecated ? "deprecated" : "stale";
|
|
@@ -2736,45 +2972,111 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
|
2736
2972
|
continue;
|
|
2737
2973
|
}
|
|
2738
2974
|
for (const rel of existing) {
|
|
2739
|
-
const abs =
|
|
2975
|
+
const abs = path19.join(root, rel);
|
|
2740
2976
|
if (dryRun) {
|
|
2741
2977
|
say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
2742
2978
|
continue;
|
|
2743
2979
|
}
|
|
2744
|
-
await
|
|
2745
|
-
await pruneEmptyDir(
|
|
2980
|
+
await fs18.remove(abs);
|
|
2981
|
+
await pruneEmptyDir(path19.dirname(abs));
|
|
2746
2982
|
say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
2747
2983
|
}
|
|
2748
2984
|
}
|
|
2749
2985
|
}
|
|
2986
|
+
async function backupBeforePrune(root, absPaths, say) {
|
|
2987
|
+
if (absPaths.length === 0) return;
|
|
2988
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2989
|
+
const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
|
|
2990
|
+
for (const abs of absPaths) {
|
|
2991
|
+
if (!await fs18.pathExists(abs)) continue;
|
|
2992
|
+
const rel = path19.relative(root, abs);
|
|
2993
|
+
const backupPath = path19.join(backupRoot, rel);
|
|
2994
|
+
await fs18.ensureDir(path19.dirname(backupPath));
|
|
2995
|
+
await fs18.copy(abs, backupPath);
|
|
2996
|
+
}
|
|
2997
|
+
say(`Backed up ${absPaths.length} path(s) to ${path19.relative(root, backupRoot)} before pruning.`);
|
|
2998
|
+
}
|
|
2999
|
+
async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
|
|
3000
|
+
const say = quiet ? () => {
|
|
3001
|
+
} : log;
|
|
3002
|
+
const orphaned = findOrphanedLockEntries(prevLock, notOrphanedIds);
|
|
3003
|
+
if (orphaned.length === 0) return;
|
|
3004
|
+
const toRemove = [];
|
|
3005
|
+
for (const entry of orphaned) {
|
|
3006
|
+
if (!entry.id) continue;
|
|
3007
|
+
const relPaths = entry.paths ?? [];
|
|
3008
|
+
if (relPaths.length === 0) continue;
|
|
3009
|
+
const existing = [];
|
|
3010
|
+
for (const rel of relPaths) {
|
|
3011
|
+
if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
|
|
3012
|
+
}
|
|
3013
|
+
if (existing.length === 0) continue;
|
|
3014
|
+
if (entry.hash === void 0) {
|
|
3015
|
+
warn(
|
|
3016
|
+
`Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
|
|
3017
|
+
);
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
const currentHash = await hashInstalledPaths(root, relPaths);
|
|
3021
|
+
if (currentHash !== entry.hash) {
|
|
3022
|
+
warn(
|
|
3023
|
+
`Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
|
|
3024
|
+
);
|
|
3025
|
+
continue;
|
|
3026
|
+
}
|
|
3027
|
+
toRemove.push({ id: entry.id, existing });
|
|
3028
|
+
}
|
|
3029
|
+
if (toRemove.length === 0) return;
|
|
3030
|
+
if (dryRun) {
|
|
3031
|
+
for (const { id, existing } of toRemove) {
|
|
3032
|
+
for (const rel of existing) {
|
|
3033
|
+
say(`[dry-run] would prune orphaned ${displayPath(root, path19.join(root, rel))} (${id})`);
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
const allAbsPaths = toRemove.flatMap(
|
|
3039
|
+
({ existing }) => existing.map((rel) => path19.join(root, rel))
|
|
3040
|
+
);
|
|
3041
|
+
await backupBeforePrune(root, allAbsPaths, say);
|
|
3042
|
+
for (const { id, existing } of toRemove) {
|
|
3043
|
+
for (const rel of existing) {
|
|
3044
|
+
const abs = path19.join(root, rel);
|
|
3045
|
+
await fs18.remove(abs);
|
|
3046
|
+
await pruneEmptyDir(path19.dirname(abs));
|
|
3047
|
+
say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
2750
3051
|
|
|
2751
3052
|
// src/refs/fetch-refs.ts
|
|
2752
|
-
import
|
|
2753
|
-
import
|
|
3053
|
+
import path21 from "node:path";
|
|
3054
|
+
import fs20 from "fs-extra";
|
|
2754
3055
|
|
|
2755
3056
|
// src/refs/cache-meta.ts
|
|
2756
|
-
import
|
|
2757
|
-
import
|
|
3057
|
+
import path20 from "node:path";
|
|
3058
|
+
import fs19 from "fs-extra";
|
|
2758
3059
|
var META_FILENAME = "cache-meta.json";
|
|
2759
3060
|
async function readCacheMeta(cacheDir) {
|
|
2760
|
-
const metaPath =
|
|
3061
|
+
const metaPath = path20.join(cacheDir, META_FILENAME);
|
|
2761
3062
|
try {
|
|
2762
|
-
const raw = await
|
|
3063
|
+
const raw = await fs19.readFile(metaPath, "utf8");
|
|
2763
3064
|
return JSON.parse(raw);
|
|
2764
3065
|
} catch {
|
|
2765
3066
|
return {};
|
|
2766
3067
|
}
|
|
2767
3068
|
}
|
|
2768
3069
|
async function writeCacheMeta(cacheDir, meta) {
|
|
2769
|
-
await
|
|
2770
|
-
const metaPath =
|
|
2771
|
-
await
|
|
3070
|
+
await fs19.ensureDir(cacheDir);
|
|
3071
|
+
const metaPath = path20.join(cacheDir, META_FILENAME);
|
|
3072
|
+
await fs19.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
2772
3073
|
}
|
|
2773
3074
|
|
|
2774
3075
|
// src/refs/fetch-refs.ts
|
|
2775
3076
|
function urlToSlug(url) {
|
|
2776
3077
|
const u = new URL(url);
|
|
2777
|
-
|
|
3078
|
+
const protocolPrefix = u.protocol === "https:" ? "" : `${u.protocol.replace(":", "")}-`;
|
|
3079
|
+
return (protocolPrefix + u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
2778
3080
|
}
|
|
2779
3081
|
function isLlmsTxtUrl(ref) {
|
|
2780
3082
|
try {
|
|
@@ -2787,8 +3089,8 @@ function isLlmsTxtUrl(ref) {
|
|
|
2787
3089
|
async function fetchSingleRef(url, cacheDir, meta) {
|
|
2788
3090
|
const existing = meta[url];
|
|
2789
3091
|
const headers = {};
|
|
2790
|
-
const safeFile = existing?.file && existing.file ===
|
|
2791
|
-
const cachedFileExists = safeFile && await
|
|
3092
|
+
const safeFile = existing?.file && existing.file === path21.basename(existing.file);
|
|
3093
|
+
const cachedFileExists = safeFile && await fs20.pathExists(path21.join(cacheDir, existing.file));
|
|
2792
3094
|
if (cachedFileExists) {
|
|
2793
3095
|
if (existing?.etag) headers["If-None-Match"] = existing.etag;
|
|
2794
3096
|
if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
|
|
@@ -2799,8 +3101,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
|
|
|
2799
3101
|
if (!res.ok) return { result: "failed" };
|
|
2800
3102
|
const text = await res.text();
|
|
2801
3103
|
const file = `${urlToSlug(url)}.md`;
|
|
2802
|
-
await
|
|
2803
|
-
await
|
|
3104
|
+
await fs20.ensureDir(cacheDir);
|
|
3105
|
+
await fs20.writeFile(path21.join(cacheDir, file), text, "utf8");
|
|
2804
3106
|
meta[url] = {
|
|
2805
3107
|
url,
|
|
2806
3108
|
etag: res.headers.get("etag") ?? void 0,
|
|
@@ -2823,8 +3125,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
|
|
|
2823
3125
|
if (keepUrls.has(url)) continue;
|
|
2824
3126
|
delete meta[url];
|
|
2825
3127
|
removed++;
|
|
2826
|
-
const safeFile = typeof entry.file === "string" && entry.file ===
|
|
2827
|
-
if (safeFile) await
|
|
3128
|
+
const safeFile = typeof entry.file === "string" && entry.file === path21.basename(entry.file);
|
|
3129
|
+
if (safeFile) await fs20.remove(path21.join(cacheDir, entry.file)).catch(() => {
|
|
2828
3130
|
});
|
|
2829
3131
|
}
|
|
2830
3132
|
if (removed > 0) await writeCacheMeta(cacheDir, meta);
|
|
@@ -2846,10 +3148,10 @@ async function fetchRefsForItems(items, cacheDir) {
|
|
|
2846
3148
|
const outcome = await fetchSingleRef(url, cacheDir, meta);
|
|
2847
3149
|
if (outcome.result === "fetched") {
|
|
2848
3150
|
summary.fetched++;
|
|
2849
|
-
summary.cachedFiles[url] =
|
|
3151
|
+
summary.cachedFiles[url] = path21.join(cacheDir, outcome.file);
|
|
2850
3152
|
} else if (outcome.result === "unchanged") {
|
|
2851
3153
|
summary.unchanged++;
|
|
2852
|
-
if (meta[url]?.file) summary.cachedFiles[url] =
|
|
3154
|
+
if (meta[url]?.file) summary.cachedFiles[url] = path21.join(cacheDir, meta[url].file);
|
|
2853
3155
|
} else {
|
|
2854
3156
|
summary.failed++;
|
|
2855
3157
|
summary.failedUrls.push(url);
|
|
@@ -2870,8 +3172,9 @@ function parseIdList(value) {
|
|
|
2870
3172
|
}
|
|
2871
3173
|
|
|
2872
3174
|
// src/commands/apply.ts
|
|
3175
|
+
var STALE_CACHE_THRESHOLD_DAYS = 7;
|
|
2873
3176
|
async function cacheHasItems() {
|
|
2874
|
-
const data = await readJson(
|
|
3177
|
+
const data = await readJson(path22.join(getCacheDir(), "manifest.json"));
|
|
2875
3178
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
2876
3179
|
}
|
|
2877
3180
|
function installableRecommendedItems(recommended) {
|
|
@@ -2944,56 +3247,428 @@ async function runApply(options) {
|
|
|
2944
3247
|
}
|
|
2945
3248
|
}
|
|
2946
3249
|
}
|
|
2947
|
-
if (!
|
|
2948
|
-
const rec = await readJson(hausPath(root, "recommendation.json"));
|
|
2949
|
-
const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
|
|
2950
|
-
const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
|
|
2951
|
-
if (catalogItemCount > 0
|
|
2952
|
-
const
|
|
2953
|
-
if (
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3250
|
+
if (!process.env["HAUS_FIXTURE_CATALOG"]) {
|
|
3251
|
+
const rec = await readJson(hausPath(root, "recommendation.json"));
|
|
3252
|
+
const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
|
|
3253
|
+
const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
|
|
3254
|
+
if (catalogItemCount > 0) {
|
|
3255
|
+
const hasItems = await cacheHasItems();
|
|
3256
|
+
if (!hasItems && !options.allowEmptyCache) {
|
|
3257
|
+
const message = "No catalog content found. Run `haus update` first.";
|
|
3258
|
+
if (isDryRun) {
|
|
3259
|
+
warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message}`);
|
|
3260
|
+
} else {
|
|
3261
|
+
error(message);
|
|
3262
|
+
process.exitCode = 1;
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
} else if (hasItems) {
|
|
3266
|
+
const cacheAgeMs = await getCacheManifestAge();
|
|
3267
|
+
if (cacheAgeMs !== null) {
|
|
3268
|
+
const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
|
|
3269
|
+
if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
|
|
3270
|
+
warn(
|
|
3271
|
+
`Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
|
|
3279
|
+
refillConfig: options.refillConfig,
|
|
3280
|
+
force: options.force,
|
|
3281
|
+
prune: options.prune
|
|
3282
|
+
});
|
|
3283
|
+
if (!isDryRun) {
|
|
3284
|
+
try {
|
|
3285
|
+
const cacheDir = hausPath(root, "llms-cache");
|
|
3286
|
+
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
3287
|
+
const installedIds = new Set(lock.map((entry) => entry.id));
|
|
3288
|
+
const allItems = await loadCatalog(root);
|
|
3289
|
+
const installedItems = allItems.filter((item) => installedIds.has(item.id));
|
|
3290
|
+
const summary = await fetchRefsForItems(installedItems, cacheDir);
|
|
3291
|
+
if (summary.fetched > 0)
|
|
3292
|
+
log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
|
|
3293
|
+
if (summary.failed > 0)
|
|
3294
|
+
warn(
|
|
3295
|
+
`Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
|
|
3296
|
+
);
|
|
3297
|
+
const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
|
|
3298
|
+
if (pruned > 0)
|
|
3299
|
+
log(
|
|
3300
|
+
`Removed ${pruned} orphaned llms.txt cache entr${pruned === 1 ? "y" : "ies"} from ${cacheDir}`
|
|
3301
|
+
);
|
|
3302
|
+
} catch (err) {
|
|
3303
|
+
warn(
|
|
3304
|
+
`Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
|
|
3305
|
+
);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
if (isDryRun) {
|
|
3309
|
+
log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
3310
|
+
} else {
|
|
3311
|
+
log("Applied files:");
|
|
3312
|
+
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
// src/commands/backups.ts
|
|
3317
|
+
import path23 from "node:path";
|
|
3318
|
+
import fs21 from "fs-extra";
|
|
3319
|
+
|
|
3320
|
+
// src/utils/prompts.ts
|
|
3321
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3322
|
+
import readline from "node:readline/promises";
|
|
3323
|
+
async function ask(question) {
|
|
3324
|
+
const rl = readline.createInterface({ input, output });
|
|
3325
|
+
try {
|
|
3326
|
+
const answer = await rl.question(`${question}
|
|
3327
|
+
> `);
|
|
3328
|
+
return answer.trim();
|
|
3329
|
+
} finally {
|
|
3330
|
+
rl.close();
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
async function confirm(question) {
|
|
3334
|
+
const answer = (await ask(`${question} [y/N]`)).toLowerCase();
|
|
3335
|
+
return answer === "y" || answer === "yes";
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
// src/commands/backups.ts
|
|
3339
|
+
var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
|
|
3340
|
+
function classify(name) {
|
|
3341
|
+
if (LOCK_BACKUP_RE.test(name)) return "lock";
|
|
3342
|
+
if (name.startsWith("undo-")) return "undo";
|
|
3343
|
+
if (name.startsWith("prune-")) return "prune";
|
|
3344
|
+
return void 0;
|
|
3345
|
+
}
|
|
3346
|
+
async function listBackups(root) {
|
|
3347
|
+
const dir = hausPath(root, "backups");
|
|
3348
|
+
if (!await fs21.pathExists(dir)) return [];
|
|
3349
|
+
const names = await fs21.readdir(dir);
|
|
3350
|
+
const entries = [];
|
|
3351
|
+
for (const name of names) {
|
|
3352
|
+
const kind = classify(name);
|
|
3353
|
+
if (!kind) continue;
|
|
3354
|
+
const absPath = path23.join(dir, name);
|
|
3355
|
+
const stat = await fs21.lstat(absPath);
|
|
3356
|
+
if (stat.isSymbolicLink()) continue;
|
|
3357
|
+
const expectDirectory = kind !== "lock";
|
|
3358
|
+
if (stat.isDirectory() !== expectDirectory) continue;
|
|
3359
|
+
entries.push({ id: name, kind, absPath, mtimeMs: stat.mtimeMs });
|
|
3360
|
+
}
|
|
3361
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
3362
|
+
return entries;
|
|
3363
|
+
}
|
|
3364
|
+
function formatAge(mtimeMs, now) {
|
|
3365
|
+
const minutes = Math.round(Math.max(0, now - mtimeMs) / 6e4);
|
|
3366
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
3367
|
+
const hours = Math.round(minutes / 60);
|
|
3368
|
+
if (hours < 48) return `${hours}h ago`;
|
|
3369
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
3370
|
+
}
|
|
3371
|
+
async function runList(root) {
|
|
3372
|
+
const entries = await listBackups(root);
|
|
3373
|
+
if (entries.length === 0) {
|
|
3374
|
+
log("No backups found under .haus-workflow/backups/.");
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
const now = Date.now();
|
|
3378
|
+
const newestFirst = [...entries].reverse();
|
|
3379
|
+
for (const entry of newestFirst) {
|
|
3380
|
+
log(`${entry.id} [${entry.kind}] ${formatAge(entry.mtimeMs, now)}`);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
async function isUnsafeWriteTarget(root, target) {
|
|
3384
|
+
const segments = path23.relative(root, target).split(path23.sep).filter(Boolean);
|
|
3385
|
+
let current = root;
|
|
3386
|
+
for (let i = 0; i < segments.length; i++) {
|
|
3387
|
+
current = path23.join(current, segments[i]);
|
|
3388
|
+
const isLastSegment = i === segments.length - 1;
|
|
3389
|
+
try {
|
|
3390
|
+
const stat = await fs21.lstat(current);
|
|
3391
|
+
if (isLastSegment) {
|
|
3392
|
+
if (!stat.isFile()) return true;
|
|
3393
|
+
} else if (!stat.isDirectory()) {
|
|
3394
|
+
return true;
|
|
3395
|
+
}
|
|
3396
|
+
} catch (err) {
|
|
3397
|
+
if (err.code === "ENOENT") return false;
|
|
3398
|
+
throw err;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
return false;
|
|
3402
|
+
}
|
|
3403
|
+
async function lstatIfRegularFile(abs) {
|
|
3404
|
+
try {
|
|
3405
|
+
const stat = await fs21.lstat(abs);
|
|
3406
|
+
return stat.isFile() ? stat : null;
|
|
3407
|
+
} catch (err) {
|
|
3408
|
+
if (err.code === "ENOENT") return null;
|
|
3409
|
+
throw err;
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
async function restoreLockBackup(root, entry, yes) {
|
|
3413
|
+
const target = hausPath(root, "haus.lock.json");
|
|
3414
|
+
if (await isUnsafeWriteTarget(root, target)) {
|
|
3415
|
+
error(
|
|
3416
|
+
`Refusing to restore: ${target} (or a parent directory) is a symlink or other non-regular file \u2014 never written through.`
|
|
3417
|
+
);
|
|
3418
|
+
return false;
|
|
3419
|
+
}
|
|
3420
|
+
if (await fs21.pathExists(target)) {
|
|
3421
|
+
const targetStat = await fs21.stat(target);
|
|
3422
|
+
if (targetStat.mtimeMs > entry.mtimeMs) {
|
|
3423
|
+
warn(
|
|
3424
|
+
`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.`
|
|
3425
|
+
);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
if (!yes) {
|
|
3429
|
+
const ok = await confirm(
|
|
3430
|
+
`Restore .haus-workflow/haus.lock.json from backup ${entry.id}? This overwrites the current lockfile.`
|
|
3431
|
+
);
|
|
3432
|
+
if (!ok) {
|
|
3433
|
+
log("Cancelled.");
|
|
3434
|
+
return false;
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
if (!await lstatIfRegularFile(entry.absPath) || await isUnsafeWriteTarget(root, target)) {
|
|
3438
|
+
error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
|
|
3439
|
+
return false;
|
|
3440
|
+
}
|
|
3441
|
+
await fs21.copy(entry.absPath, target, { overwrite: true });
|
|
3442
|
+
log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
|
|
3443
|
+
return true;
|
|
3444
|
+
}
|
|
3445
|
+
async function collectFilesRecursive(dir) {
|
|
3446
|
+
const files = [];
|
|
3447
|
+
const skippedLinks = [];
|
|
3448
|
+
const skippedOther = [];
|
|
3449
|
+
const names = await fs21.readdir(dir);
|
|
3450
|
+
for (const name of names) {
|
|
3451
|
+
const abs = path23.join(dir, name);
|
|
3452
|
+
const stat = await fs21.lstat(abs);
|
|
3453
|
+
if (stat.isSymbolicLink()) {
|
|
3454
|
+
skippedLinks.push(abs);
|
|
3455
|
+
continue;
|
|
3456
|
+
}
|
|
3457
|
+
if (stat.isDirectory()) {
|
|
3458
|
+
const nested = await collectFilesRecursive(abs);
|
|
3459
|
+
files.push(...nested.files);
|
|
3460
|
+
skippedLinks.push(...nested.skippedLinks);
|
|
3461
|
+
skippedOther.push(...nested.skippedOther);
|
|
3462
|
+
} else if (stat.isFile()) {
|
|
3463
|
+
files.push(abs);
|
|
3464
|
+
} else {
|
|
3465
|
+
skippedOther.push(abs);
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
return { files, skippedLinks, skippedOther };
|
|
3469
|
+
}
|
|
3470
|
+
async function restoreDirBackup(root, entry, yes) {
|
|
3471
|
+
const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
|
|
3472
|
+
if (skippedLinks.length > 0) {
|
|
3473
|
+
warn(
|
|
3474
|
+
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
|
|
3475
|
+
);
|
|
3476
|
+
}
|
|
3477
|
+
if (skippedOther.length > 0) {
|
|
3478
|
+
warn(
|
|
3479
|
+
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
|
|
3480
|
+
);
|
|
3481
|
+
}
|
|
3482
|
+
if (files.length === 0) {
|
|
3483
|
+
log(`Backup ${entry.id} contains no files.`);
|
|
3484
|
+
return false;
|
|
3485
|
+
}
|
|
3486
|
+
const safeFiles = [];
|
|
3487
|
+
const unsafeTargets = [];
|
|
3488
|
+
for (const abs of files) {
|
|
3489
|
+
const rel = path23.relative(entry.absPath, abs);
|
|
3490
|
+
const target = path23.join(root, rel);
|
|
3491
|
+
if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
|
|
3492
|
+
else safeFiles.push(abs);
|
|
3493
|
+
}
|
|
3494
|
+
if (unsafeTargets.length > 0) {
|
|
3495
|
+
warn(
|
|
3496
|
+
`Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path \u2014 never written through: ${unsafeTargets.join(", ")}`
|
|
3497
|
+
);
|
|
3498
|
+
}
|
|
3499
|
+
if (safeFiles.length === 0) {
|
|
3500
|
+
log(`Backup ${entry.id} has no restorable targets.`);
|
|
3501
|
+
return false;
|
|
3502
|
+
}
|
|
3503
|
+
const stale = [];
|
|
3504
|
+
const vanished = [];
|
|
3505
|
+
const toRestore = [];
|
|
3506
|
+
for (const abs of safeFiles) {
|
|
3507
|
+
const rel = path23.relative(entry.absPath, abs);
|
|
3508
|
+
const backupFileStat = await lstatIfRegularFile(abs);
|
|
3509
|
+
if (!backupFileStat) {
|
|
3510
|
+
vanished.push(rel);
|
|
3511
|
+
continue;
|
|
3512
|
+
}
|
|
3513
|
+
toRestore.push(abs);
|
|
3514
|
+
const target = path23.join(root, rel);
|
|
3515
|
+
if (await fs21.pathExists(target)) {
|
|
3516
|
+
const targetStat = await fs21.stat(target);
|
|
3517
|
+
if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
|
|
3518
|
+
stale.push({
|
|
3519
|
+
rel,
|
|
3520
|
+
targetMtimeMs: targetStat.mtimeMs,
|
|
3521
|
+
backupMtimeMs: backupFileStat.mtimeMs
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
if (vanished.length > 0) {
|
|
3527
|
+
warn(
|
|
3528
|
+
`Skipped ${vanished.length} backup file(s) that changed (no longer a plain file) since being listed: ${vanished.join(", ")}`
|
|
3529
|
+
);
|
|
3530
|
+
}
|
|
3531
|
+
if (toRestore.length === 0) {
|
|
3532
|
+
log(`Backup ${entry.id} has no restorable files left after re-validation.`);
|
|
3533
|
+
return false;
|
|
3534
|
+
}
|
|
3535
|
+
if (stale.length > 0) {
|
|
3536
|
+
const detail = stale.map(
|
|
3537
|
+
(s) => `${s.rel} (destination: ${new Date(s.targetMtimeMs).toISOString()}, backup: ${new Date(s.backupMtimeMs).toISOString()})`
|
|
3538
|
+
).join("; ");
|
|
3539
|
+
warn(
|
|
3540
|
+
`${stale.length} file(s) at the restore destination are newer than backup ${entry.id} and will be overwritten: ${detail}`
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
if (!yes) {
|
|
3544
|
+
const ok = await confirm(
|
|
3545
|
+
`Restore ${toRestore.length} file(s) from backup ${entry.id} to their original paths?`
|
|
3546
|
+
);
|
|
3547
|
+
if (!ok) {
|
|
3548
|
+
log("Cancelled.");
|
|
3549
|
+
return false;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
let restoredCount = 0;
|
|
3553
|
+
const skippedAtWrite = [];
|
|
3554
|
+
for (const abs of toRestore) {
|
|
3555
|
+
const rel = path23.relative(entry.absPath, abs);
|
|
3556
|
+
const target = path23.join(root, rel);
|
|
3557
|
+
if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
|
|
3558
|
+
skippedAtWrite.push(rel);
|
|
3559
|
+
continue;
|
|
3560
|
+
}
|
|
3561
|
+
await fs21.ensureDir(path23.dirname(target));
|
|
3562
|
+
await fs21.copy(abs, target, { overwrite: true });
|
|
3563
|
+
restoredCount++;
|
|
3564
|
+
}
|
|
3565
|
+
if (skippedAtWrite.length > 0) {
|
|
3566
|
+
warn(
|
|
3567
|
+
`Skipped ${skippedAtWrite.length} file(s) that changed since being checked, right before restoring: ${skippedAtWrite.join(", ")}`
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
log(`Restored ${restoredCount} file(s) from ${entry.id}.`);
|
|
3571
|
+
return restoredCount > 0;
|
|
3572
|
+
}
|
|
3573
|
+
async function runRestore(root, id, yes) {
|
|
3574
|
+
const entries = await listBackups(root);
|
|
3575
|
+
const entry = entries.find((e) => e.id === id);
|
|
3576
|
+
if (!entry) {
|
|
3577
|
+
error(`No backup found with id "${id}". Run \`haus backups list\` to see available backups.`);
|
|
3578
|
+
return false;
|
|
3579
|
+
}
|
|
3580
|
+
return entry.kind === "lock" ? restoreLockBackup(root, entry, yes) : restoreDirBackup(root, entry, yes);
|
|
3581
|
+
}
|
|
3582
|
+
async function runPrune(root, opts) {
|
|
3583
|
+
if (opts.olderThanDays === void 0 && opts.keep === void 0) {
|
|
3584
|
+
error("Specify --older-than <days> or --keep <n> \u2014 refusing to prune with no bound.");
|
|
3585
|
+
return false;
|
|
3586
|
+
}
|
|
3587
|
+
const entries = await listBackups(root);
|
|
3588
|
+
const now = Date.now();
|
|
3589
|
+
const toRemove = /* @__PURE__ */ new Set();
|
|
3590
|
+
if (opts.olderThanDays !== void 0) {
|
|
3591
|
+
const cutoff = now - opts.olderThanDays * 24 * 60 * 60 * 1e3;
|
|
3592
|
+
for (const entry of entries) if (entry.mtimeMs < cutoff) toRemove.add(entry.id);
|
|
3593
|
+
}
|
|
3594
|
+
if (opts.keep !== void 0) {
|
|
3595
|
+
const excess = entries.length - opts.keep;
|
|
3596
|
+
if (excess > 0) for (const entry of entries.slice(0, excess)) toRemove.add(entry.id);
|
|
3597
|
+
}
|
|
3598
|
+
if (toRemove.size === 0) {
|
|
3599
|
+
log("Nothing to prune.");
|
|
3600
|
+
return true;
|
|
3601
|
+
}
|
|
3602
|
+
const removeList = entries.filter((e) => toRemove.has(e.id));
|
|
3603
|
+
const keepCount = entries.length - removeList.length;
|
|
3604
|
+
if (!opts.yes) {
|
|
3605
|
+
const ok = await confirm(
|
|
3606
|
+
`Remove ${removeList.length} backup(s)?
|
|
3607
|
+
${removeList.map((e) => e.id).join("\n ")}
|
|
3608
|
+
Keeping ${keepCount}.`
|
|
3609
|
+
);
|
|
3610
|
+
if (!ok) {
|
|
3611
|
+
log("Cancelled.");
|
|
3612
|
+
return false;
|
|
2960
3613
|
}
|
|
2961
3614
|
}
|
|
2962
|
-
const
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
});
|
|
2966
|
-
if (!isDryRun) {
|
|
3615
|
+
const removed = [];
|
|
3616
|
+
const failed = [];
|
|
3617
|
+
for (const entry of removeList) {
|
|
2967
3618
|
try {
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
const installedIds = new Set(lock.map((entry) => entry.id));
|
|
2971
|
-
const allItems = await loadCatalog(root);
|
|
2972
|
-
const installedItems = allItems.filter((item) => installedIds.has(item.id));
|
|
2973
|
-
const summary = await fetchRefsForItems(installedItems, cacheDir);
|
|
2974
|
-
if (summary.fetched > 0)
|
|
2975
|
-
log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
|
|
2976
|
-
if (summary.failed > 0)
|
|
2977
|
-
warn(
|
|
2978
|
-
`Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
|
|
2979
|
-
);
|
|
2980
|
-
const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
|
|
2981
|
-
if (pruned > 0)
|
|
2982
|
-
log(
|
|
2983
|
-
`Removed ${pruned} orphaned llms.txt cache entr${pruned === 1 ? "y" : "ies"} from ${cacheDir}`
|
|
2984
|
-
);
|
|
3619
|
+
await fs21.remove(entry.absPath);
|
|
3620
|
+
removed.push(entry.id);
|
|
2985
3621
|
} catch (err) {
|
|
2986
|
-
|
|
2987
|
-
|
|
3622
|
+
failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
log(`Removed ${removed.length} backup(s), kept ${entries.length - removed.length}.`);
|
|
3626
|
+
if (failed.length > 0) {
|
|
3627
|
+
warn(
|
|
3628
|
+
`Failed to remove ${failed.length} backup(s): ${failed.map((f) => `${f.id} (${f.message})`).join(", ")}`
|
|
3629
|
+
);
|
|
3630
|
+
return false;
|
|
3631
|
+
}
|
|
3632
|
+
return true;
|
|
3633
|
+
}
|
|
3634
|
+
async function runBackups(action, opts = {}) {
|
|
3635
|
+
const root = opts.root ?? process.cwd();
|
|
3636
|
+
if (action === "list") {
|
|
3637
|
+
await runList(root);
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3640
|
+
if (action === "restore") {
|
|
3641
|
+
if (!opts.id) {
|
|
3642
|
+
error("Usage: haus backups restore <id>");
|
|
3643
|
+
process.exitCode = 1;
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
if (!await runRestore(root, opts.id, Boolean(opts.yes))) process.exitCode = 1;
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
let olderThanDays;
|
|
3650
|
+
if (opts.olderThan !== void 0) {
|
|
3651
|
+
const n = Number(opts.olderThan);
|
|
3652
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
3653
|
+
error(
|
|
3654
|
+
`Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`
|
|
2988
3655
|
);
|
|
3656
|
+
process.exitCode = 1;
|
|
3657
|
+
return;
|
|
2989
3658
|
}
|
|
3659
|
+
olderThanDays = n;
|
|
2990
3660
|
}
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
3661
|
+
let keep;
|
|
3662
|
+
if (opts.keep !== void 0) {
|
|
3663
|
+
const n = Number(opts.keep);
|
|
3664
|
+
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
|
|
3665
|
+
error(`Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`);
|
|
3666
|
+
process.exitCode = 1;
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
keep = n;
|
|
2996
3670
|
}
|
|
3671
|
+
if (!await runPrune(root, { olderThanDays, keep, yes: Boolean(opts.yes) })) process.exitCode = 1;
|
|
2997
3672
|
}
|
|
2998
3673
|
|
|
2999
3674
|
// src/commands/catalog-audit.ts
|
|
@@ -3024,7 +3699,7 @@ async function runCatalogAudit() {
|
|
|
3024
3699
|
// src/commands/clone.ts
|
|
3025
3700
|
init_exec();
|
|
3026
3701
|
import { existsSync as existsSync2 } from "node:fs";
|
|
3027
|
-
import
|
|
3702
|
+
import path24 from "node:path";
|
|
3028
3703
|
var GIT_LOCATION_VARS = [
|
|
3029
3704
|
"GIT_DIR",
|
|
3030
3705
|
"GIT_WORK_TREE",
|
|
@@ -3044,15 +3719,50 @@ function repoNameFromUrl(url) {
|
|
|
3044
3719
|
const tail = trimmed.split(/[/:]/).pop() ?? "";
|
|
3045
3720
|
return tail || "repo";
|
|
3046
3721
|
}
|
|
3722
|
+
function normalizeGitUrl(url) {
|
|
3723
|
+
let s = url.trim();
|
|
3724
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
3725
|
+
s = s.replace(/^[^/@]+@/, "");
|
|
3726
|
+
s = s.replace(/:(?=[^/])/, "/");
|
|
3727
|
+
s = s.replace(/\/+$/, "");
|
|
3728
|
+
s = s.replace(/\.git$/i, "");
|
|
3729
|
+
return s.toLowerCase();
|
|
3730
|
+
}
|
|
3731
|
+
async function checkExistingTarget(target, url) {
|
|
3732
|
+
const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
|
|
3733
|
+
env: cloneEnv(),
|
|
3734
|
+
extendEnv: false
|
|
3735
|
+
});
|
|
3736
|
+
if (isRepo.exitCode !== 0) return { kind: "not-a-repo" };
|
|
3737
|
+
const origin = await runGit(["-C", target, "remote", "get-url", "origin"], {
|
|
3738
|
+
env: cloneEnv(),
|
|
3739
|
+
extendEnv: false
|
|
3740
|
+
});
|
|
3741
|
+
if (origin.exitCode !== 0) return { kind: "different-repo", origin: null };
|
|
3742
|
+
const originUrl = origin.stdout.trim();
|
|
3743
|
+
if (normalizeGitUrl(originUrl) === normalizeGitUrl(url)) return { kind: "matches" };
|
|
3744
|
+
return { kind: "different-repo", origin: originUrl };
|
|
3745
|
+
}
|
|
3047
3746
|
async function runClone(url, opts = {}) {
|
|
3048
3747
|
if (!url || !url.trim()) {
|
|
3049
3748
|
error("A git URL is required: `haus clone <url> [dir]`.");
|
|
3050
3749
|
process.exitCode = 1;
|
|
3051
3750
|
return;
|
|
3052
3751
|
}
|
|
3053
|
-
const target =
|
|
3752
|
+
const target = path24.resolve(opts.dir?.trim() || repoNameFromUrl(url));
|
|
3054
3753
|
if (existsSync2(target)) {
|
|
3055
|
-
|
|
3754
|
+
const check = await checkExistingTarget(target, url);
|
|
3755
|
+
if (check.kind === "matches") {
|
|
3756
|
+
log(`\u2022 ${path24.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
|
|
3757
|
+
return;
|
|
3758
|
+
}
|
|
3759
|
+
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].`;
|
|
3760
|
+
if (opts.dryRun) {
|
|
3761
|
+
warn(`[dry-run] ${message}`);
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
error(message);
|
|
3765
|
+
process.exitCode = 1;
|
|
3056
3766
|
return;
|
|
3057
3767
|
}
|
|
3058
3768
|
if (opts.dryRun) {
|
|
@@ -3123,8 +3833,8 @@ async function resolveBaseRef(root) {
|
|
|
3123
3833
|
}
|
|
3124
3834
|
|
|
3125
3835
|
// src/decisions/check.ts
|
|
3126
|
-
import
|
|
3127
|
-
import
|
|
3836
|
+
import path27 from "node:path";
|
|
3837
|
+
import fs24 from "fs-extra";
|
|
3128
3838
|
|
|
3129
3839
|
// src/decisions/diff.ts
|
|
3130
3840
|
init_exec();
|
|
@@ -3179,22 +3889,22 @@ function matchesAnyRegex(filePath, patterns) {
|
|
|
3179
3889
|
}
|
|
3180
3890
|
|
|
3181
3891
|
// src/decisions/paths.ts
|
|
3182
|
-
import
|
|
3183
|
-
import
|
|
3892
|
+
import path25 from "node:path";
|
|
3893
|
+
import fs22 from "fs-extra";
|
|
3184
3894
|
async function resolveDecisionsDir(root) {
|
|
3185
|
-
const primary =
|
|
3186
|
-
if (await
|
|
3187
|
-
const legacy =
|
|
3188
|
-
if (await
|
|
3895
|
+
const primary = path25.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
3896
|
+
if (await fs22.pathExists(primary)) return primary;
|
|
3897
|
+
const legacy = path25.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
3898
|
+
if (await fs22.pathExists(legacy)) return legacy;
|
|
3189
3899
|
return primary;
|
|
3190
3900
|
}
|
|
3191
3901
|
function relativeDecisionPath(decisionsDir, root) {
|
|
3192
|
-
return
|
|
3902
|
+
return path25.relative(root, decisionsDir).replace(/\\/g, "/");
|
|
3193
3903
|
}
|
|
3194
3904
|
|
|
3195
3905
|
// src/decisions/validate.ts
|
|
3196
|
-
import
|
|
3197
|
-
import
|
|
3906
|
+
import path26 from "node:path";
|
|
3907
|
+
import fs23 from "fs-extra";
|
|
3198
3908
|
function validateDecisionContent(content2) {
|
|
3199
3909
|
const errors = [];
|
|
3200
3910
|
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
@@ -3223,14 +3933,14 @@ function decisionNumberFromFile(name) {
|
|
|
3223
3933
|
return match?.[1] ?? null;
|
|
3224
3934
|
}
|
|
3225
3935
|
async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
3226
|
-
const readmePath =
|
|
3227
|
-
if (!await
|
|
3936
|
+
const readmePath = path26.join(decisionsDir, "README.md");
|
|
3937
|
+
if (!await fs23.pathExists(readmePath)) {
|
|
3228
3938
|
return ["docs/decisions/README.md is missing"];
|
|
3229
3939
|
}
|
|
3230
3940
|
const readme = await readText(readmePath) ?? "";
|
|
3231
3941
|
const errors = [];
|
|
3232
3942
|
for (const file of decisionFiles) {
|
|
3233
|
-
const num = decisionNumberFromFile(
|
|
3943
|
+
const num = decisionNumberFromFile(path26.basename(file));
|
|
3234
3944
|
if (num && !indexReferencesNumber(readme, num)) {
|
|
3235
3945
|
errors.push(`README.md missing index row for ADR-${num}`);
|
|
3236
3946
|
}
|
|
@@ -3284,22 +3994,22 @@ async function checkSatisfaction(input2) {
|
|
|
3284
3994
|
const errors = [];
|
|
3285
3995
|
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
3286
3996
|
const newDecisionFiles = input2.changedFiles.filter(
|
|
3287
|
-
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(
|
|
3997
|
+
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path27.basename(f))
|
|
3288
3998
|
);
|
|
3289
3999
|
if (newDecisionFiles.length === 0) {
|
|
3290
4000
|
errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
|
|
3291
4001
|
return errors;
|
|
3292
4002
|
}
|
|
3293
|
-
const decisionsDir =
|
|
4003
|
+
const decisionsDir = path27.join(input2.root, input2.decisionsRel);
|
|
3294
4004
|
for (const rel of newDecisionFiles) {
|
|
3295
|
-
const abs =
|
|
4005
|
+
const abs = path27.join(input2.root, rel);
|
|
3296
4006
|
const content2 = await readText(abs) ?? "";
|
|
3297
4007
|
const valid = validateDecisionContent(content2);
|
|
3298
4008
|
if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
3299
4009
|
}
|
|
3300
4010
|
const indexErrors = await validateIndexSync(
|
|
3301
4011
|
decisionsDir,
|
|
3302
|
-
newDecisionFiles.map((f) =>
|
|
4012
|
+
newDecisionFiles.map((f) => path27.basename(f))
|
|
3303
4013
|
);
|
|
3304
4014
|
errors.push(...indexErrors);
|
|
3305
4015
|
return errors;
|
|
@@ -3343,8 +4053,8 @@ ${opts.commitMessages ?? ""}`;
|
|
|
3343
4053
|
changedFiles: stats.files
|
|
3344
4054
|
});
|
|
3345
4055
|
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
3346
|
-
const legacyExists = await
|
|
3347
|
-
if (legacyExists && !await
|
|
4056
|
+
const legacyExists = await fs24.pathExists(path27.join(root, legacyRel));
|
|
4057
|
+
if (legacyExists && !await fs24.pathExists(decisionsDir)) {
|
|
3348
4058
|
satisfactionErrors = await checkSatisfaction({
|
|
3349
4059
|
root,
|
|
3350
4060
|
decisionsRel: legacyRel,
|
|
@@ -3364,8 +4074,8 @@ ${opts.commitMessages ?? ""}`;
|
|
|
3364
4074
|
return { triggered: true, satisfied: false, reasons, securityTriggered };
|
|
3365
4075
|
}
|
|
3366
4076
|
async function listDecisionNumbers(decisionsDir) {
|
|
3367
|
-
if (!await
|
|
3368
|
-
const entries = await
|
|
4077
|
+
if (!await fs24.pathExists(decisionsDir)) return [];
|
|
4078
|
+
const entries = await fs24.readdir(decisionsDir);
|
|
3369
4079
|
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
3370
4080
|
}
|
|
3371
4081
|
|
|
@@ -3407,7 +4117,7 @@ async function runDecisionsGuard(options = {}) {
|
|
|
3407
4117
|
}
|
|
3408
4118
|
|
|
3409
4119
|
// src/commands/decisions.ts
|
|
3410
|
-
import
|
|
4120
|
+
import path28 from "node:path";
|
|
3411
4121
|
|
|
3412
4122
|
// src/decisions/next-number.ts
|
|
3413
4123
|
async function nextDecisionNumber(root) {
|
|
@@ -3518,7 +4228,7 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
3518
4228
|
return;
|
|
3519
4229
|
}
|
|
3520
4230
|
if (sub === "validate") {
|
|
3521
|
-
const target = arg ?
|
|
4231
|
+
const target = arg ? path28.resolve(root, arg) : void 0;
|
|
3522
4232
|
if (!target) {
|
|
3523
4233
|
error("usage: haus decisions validate <path-to-decision.md>");
|
|
3524
4234
|
process.exitCode = 1;
|
|
@@ -3535,18 +4245,18 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
3535
4245
|
}
|
|
3536
4246
|
|
|
3537
4247
|
// src/commands/doctor.ts
|
|
3538
|
-
import
|
|
3539
|
-
import
|
|
4248
|
+
import path35 from "node:path";
|
|
4249
|
+
import fs28 from "fs-extra";
|
|
3540
4250
|
|
|
3541
4251
|
// src/decisions/doctor.ts
|
|
3542
|
-
import
|
|
3543
|
-
import
|
|
4252
|
+
import path29 from "node:path";
|
|
4253
|
+
import fs25 from "fs-extra";
|
|
3544
4254
|
async function auditDecisionsLayout(root) {
|
|
3545
4255
|
const findings = [];
|
|
3546
|
-
const primary =
|
|
3547
|
-
const legacy =
|
|
3548
|
-
const hasPrimary = await
|
|
3549
|
-
const hasLegacy = await
|
|
4256
|
+
const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4257
|
+
const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4258
|
+
const hasPrimary = await fs25.pathExists(primary);
|
|
4259
|
+
const hasLegacy = await fs25.pathExists(legacy);
|
|
3550
4260
|
if (!hasPrimary && hasLegacy) {
|
|
3551
4261
|
findings.push({
|
|
3552
4262
|
level: "advisory",
|
|
@@ -3559,8 +4269,8 @@ async function auditDecisionsLayout(root) {
|
|
|
3559
4269
|
if (!hasPrimary) return findings;
|
|
3560
4270
|
const decisionsDir = await resolveDecisionsDir(root);
|
|
3561
4271
|
const rel = relativeDecisionPath(decisionsDir, root);
|
|
3562
|
-
const readmePath =
|
|
3563
|
-
if (!await
|
|
4272
|
+
const readmePath = path29.join(decisionsDir, "README.md");
|
|
4273
|
+
if (!await fs25.pathExists(readmePath)) {
|
|
3564
4274
|
findings.push({
|
|
3565
4275
|
level: "advisory",
|
|
3566
4276
|
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
@@ -3570,7 +4280,7 @@ async function auditDecisionsLayout(root) {
|
|
|
3570
4280
|
return findings;
|
|
3571
4281
|
}
|
|
3572
4282
|
const readme = await readText(readmePath);
|
|
3573
|
-
const entries = (await
|
|
4283
|
+
const entries = (await fs25.readdir(decisionsDir)).filter(
|
|
3574
4284
|
(f) => f.endsWith(".md") && f !== "README.md"
|
|
3575
4285
|
);
|
|
3576
4286
|
for (const file of entries) {
|
|
@@ -3584,7 +4294,7 @@ async function auditDecisionsLayout(root) {
|
|
|
3584
4294
|
});
|
|
3585
4295
|
}
|
|
3586
4296
|
}
|
|
3587
|
-
const claude = await readText(
|
|
4297
|
+
const claude = await readText(path29.join(root, "CLAUDE.md"));
|
|
3588
4298
|
if (claude && !claude.includes(`@${rel}/README.md`)) {
|
|
3589
4299
|
findings.push({
|
|
3590
4300
|
level: "advisory",
|
|
@@ -3614,8 +4324,12 @@ async function auditDecisionsLayout(root) {
|
|
|
3614
4324
|
return findings;
|
|
3615
4325
|
}
|
|
3616
4326
|
|
|
4327
|
+
// src/scanner/read-context.ts
|
|
4328
|
+
import path34 from "node:path";
|
|
4329
|
+
import fs27 from "fs-extra";
|
|
4330
|
+
|
|
3617
4331
|
// src/scanner/scan-project.ts
|
|
3618
|
-
import
|
|
4332
|
+
import path33 from "node:path";
|
|
3619
4333
|
|
|
3620
4334
|
// src/utils/versions.ts
|
|
3621
4335
|
import semver from "semver";
|
|
@@ -3637,8 +4351,8 @@ function compareVersions(a, b) {
|
|
|
3637
4351
|
}
|
|
3638
4352
|
|
|
3639
4353
|
// src/scanner/detect-package-manager.ts
|
|
3640
|
-
import
|
|
3641
|
-
import
|
|
4354
|
+
import path30 from "node:path";
|
|
4355
|
+
import fs26 from "fs-extra";
|
|
3642
4356
|
function detectPackageManager(root, packageManagerField) {
|
|
3643
4357
|
const field = String(packageManagerField ?? "").trim();
|
|
3644
4358
|
if (field.startsWith("yarn@")) {
|
|
@@ -3656,9 +4370,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
3656
4370
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
3657
4371
|
return "unknown";
|
|
3658
4372
|
}
|
|
3659
|
-
if (
|
|
3660
|
-
if (
|
|
3661
|
-
if (
|
|
4373
|
+
if (fs26.existsSync(path30.join(root, "yarn.lock"))) return "yarn";
|
|
4374
|
+
if (fs26.existsSync(path30.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
4375
|
+
if (fs26.existsSync(path30.join(root, "package-lock.json"))) return "npm";
|
|
3662
4376
|
return "unknown";
|
|
3663
4377
|
}
|
|
3664
4378
|
|
|
@@ -3860,7 +4574,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
3860
4574
|
}
|
|
3861
4575
|
|
|
3862
4576
|
// src/scanner/detection.ts
|
|
3863
|
-
import
|
|
4577
|
+
import path31 from "node:path";
|
|
3864
4578
|
var UNSUPPORTED_MARKERS = {
|
|
3865
4579
|
"requirements.txt": "python",
|
|
3866
4580
|
"pyproject.toml": "python",
|
|
@@ -3914,14 +4628,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
3914
4628
|
function collectUnsupportedSignals(files) {
|
|
3915
4629
|
return [
|
|
3916
4630
|
...new Set(
|
|
3917
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
4631
|
+
files.map((f) => UNSUPPORTED_MARKERS[path31.basename(f)]).filter((s) => Boolean(s))
|
|
3918
4632
|
)
|
|
3919
4633
|
].sort();
|
|
3920
4634
|
}
|
|
3921
4635
|
|
|
3922
4636
|
// src/scanner/render.ts
|
|
3923
4637
|
import { readFile } from "node:fs/promises";
|
|
3924
|
-
import
|
|
4638
|
+
import path32 from "node:path";
|
|
3925
4639
|
async function buildContentBlob(root, files) {
|
|
3926
4640
|
const candidates = files.filter(
|
|
3927
4641
|
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
@@ -3929,7 +4643,7 @@ async function buildContentBlob(root, files) {
|
|
|
3929
4643
|
const slice = candidates.slice(0, 300);
|
|
3930
4644
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
3931
4645
|
try {
|
|
3932
|
-
return await readFile(
|
|
4646
|
+
return await readFile(path32.join(root, rel), "utf8");
|
|
3933
4647
|
} catch {
|
|
3934
4648
|
return "";
|
|
3935
4649
|
}
|
|
@@ -4016,8 +4730,8 @@ var SAFE_FILES = [
|
|
|
4016
4730
|
"Gemfile"
|
|
4017
4731
|
];
|
|
4018
4732
|
async function scanProject(root) {
|
|
4019
|
-
const pkg = await readJson(
|
|
4020
|
-
const composer = await readJson(
|
|
4733
|
+
const pkg = await readJson(path33.join(root, "package.json"));
|
|
4734
|
+
const composer = await readJson(path33.join(root, "composer.json"));
|
|
4021
4735
|
const files = await listFiles(root, SAFE_FILES);
|
|
4022
4736
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
4023
4737
|
const deps = dependencySet(pkg, composer);
|
|
@@ -4044,7 +4758,7 @@ async function scanProject(root) {
|
|
|
4044
4758
|
const context = {
|
|
4045
4759
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4046
4760
|
root,
|
|
4047
|
-
repoName: String(pkg?.name ??
|
|
4761
|
+
repoName: String(pkg?.name ?? path33.basename(root)),
|
|
4048
4762
|
packageManager,
|
|
4049
4763
|
repoRoles: roles,
|
|
4050
4764
|
detectedStacks: stacks,
|
|
@@ -4061,9 +4775,16 @@ async function scanProject(root) {
|
|
|
4061
4775
|
}
|
|
4062
4776
|
|
|
4063
4777
|
// src/scanner/read-context.ts
|
|
4778
|
+
async function isCacheFresh(root, cachePath) {
|
|
4779
|
+
const pkgJsonPath = path34.join(root, "package.json");
|
|
4780
|
+
if (!await fs27.pathExists(pkgJsonPath)) return true;
|
|
4781
|
+
const [cacheStat, pkgStat] = await Promise.all([fs27.stat(cachePath), fs27.stat(pkgJsonPath)]);
|
|
4782
|
+
return cacheStat.mtimeMs >= pkgStat.mtimeMs;
|
|
4783
|
+
}
|
|
4064
4784
|
async function readContextOrScan(root) {
|
|
4065
|
-
const
|
|
4066
|
-
|
|
4785
|
+
const cachePath = hausPath(root, "context-map.json");
|
|
4786
|
+
const context = await readJson(cachePath);
|
|
4787
|
+
if (context && await isCacheFresh(root, cachePath)) return context;
|
|
4067
4788
|
const scan = await scanProject(root);
|
|
4068
4789
|
return scan;
|
|
4069
4790
|
}
|
|
@@ -4156,7 +4877,7 @@ async function runDoctor(options) {
|
|
|
4156
4877
|
} else {
|
|
4157
4878
|
ok(`- HOOKS OK: ${hooks.message}`);
|
|
4158
4879
|
}
|
|
4159
|
-
const rootClaudeMdPath =
|
|
4880
|
+
const rootClaudeMdPath = path35.join(root, "CLAUDE.md");
|
|
4160
4881
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
4161
4882
|
if (!rootClaudeMdContent) {
|
|
4162
4883
|
flag(
|
|
@@ -4184,7 +4905,7 @@ async function runDoctor(options) {
|
|
|
4184
4905
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
4185
4906
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
4186
4907
|
for (const target of importTargets) {
|
|
4187
|
-
if (!await
|
|
4908
|
+
if (!await fs28.pathExists(hausPath(root, target))) {
|
|
4188
4909
|
flag(
|
|
4189
4910
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
4190
4911
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -4195,7 +4916,7 @@ async function runDoctor(options) {
|
|
|
4195
4916
|
}
|
|
4196
4917
|
}
|
|
4197
4918
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
4198
|
-
const workflowExists = await
|
|
4919
|
+
const workflowExists = await fs28.pathExists(workflowPath);
|
|
4199
4920
|
if (!workflowExists) {
|
|
4200
4921
|
flag(
|
|
4201
4922
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -4210,43 +4931,36 @@ async function runDoctor(options) {
|
|
|
4210
4931
|
} else {
|
|
4211
4932
|
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
4212
4933
|
const bodyContent = workflowContent.slice(firstLine.length + 1);
|
|
4213
|
-
const
|
|
4214
|
-
|
|
4934
|
+
const cachePath = path35.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
4935
|
+
const bundledPath = path35.join(
|
|
4936
|
+
packageRoot(),
|
|
4937
|
+
"library",
|
|
4938
|
+
"global",
|
|
4939
|
+
"templates",
|
|
4940
|
+
"agentic-workflow-standard.md"
|
|
4941
|
+
);
|
|
4942
|
+
const templatePath = await fs28.pathExists(cachePath) ? cachePath : bundledPath;
|
|
4943
|
+
const templateContent = await readText(templatePath);
|
|
4944
|
+
const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
|
|
4945
|
+
if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
|
|
4215
4946
|
flag(
|
|
4216
4947
|
"- .haus-workflow/WORKFLOW.md: modified locally (run `haus apply --write --force` to restore)",
|
|
4217
4948
|
"The workflow standard file was edited after haus wrote it",
|
|
4218
4949
|
"haus apply --write --force"
|
|
4219
4950
|
);
|
|
4220
|
-
} else {
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
"
|
|
4225
|
-
"global",
|
|
4226
|
-
"templates",
|
|
4227
|
-
"agentic-workflow-standard.md"
|
|
4951
|
+
} else if (verdict.status === "stale") {
|
|
4952
|
+
suggest(
|
|
4953
|
+
"- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
|
|
4954
|
+
"The workflow standard is out of date",
|
|
4955
|
+
"haus apply --write"
|
|
4228
4956
|
);
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
if (storedHashMatch && templateContent) {
|
|
4232
|
-
const currentHash = hashText(normaliseLF(templateContent));
|
|
4233
|
-
if (storedHashMatch[1] !== currentHash) {
|
|
4234
|
-
suggest(
|
|
4235
|
-
"- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
|
|
4236
|
-
"The workflow standard is out of date",
|
|
4237
|
-
"haus apply --write"
|
|
4238
|
-
);
|
|
4239
|
-
} else {
|
|
4240
|
-
ok("- .haus-workflow/WORKFLOW.md: OK");
|
|
4241
|
-
}
|
|
4242
|
-
} else {
|
|
4243
|
-
ok("- .haus-workflow/WORKFLOW.md: OK");
|
|
4244
|
-
}
|
|
4957
|
+
} else {
|
|
4958
|
+
ok("- .haus-workflow/WORKFLOW.md: OK");
|
|
4245
4959
|
}
|
|
4246
4960
|
}
|
|
4247
4961
|
}
|
|
4248
4962
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
4249
|
-
const workflowConfigExists = await
|
|
4963
|
+
const workflowConfigExists = await fs28.pathExists(workflowConfigPath);
|
|
4250
4964
|
if (!workflowConfigExists) {
|
|
4251
4965
|
flag(
|
|
4252
4966
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -4254,7 +4968,7 @@ async function runDoctor(options) {
|
|
|
4254
4968
|
"haus apply --write"
|
|
4255
4969
|
);
|
|
4256
4970
|
} else {
|
|
4257
|
-
const cfg = await
|
|
4971
|
+
const cfg = await fs28.readFile(workflowConfigPath, "utf8");
|
|
4258
4972
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
4259
4973
|
if (unfilled > 0) {
|
|
4260
4974
|
suggest(
|
|
@@ -4267,15 +4981,17 @@ async function runDoctor(options) {
|
|
|
4267
4981
|
}
|
|
4268
4982
|
}
|
|
4269
4983
|
if (workflowExists) {
|
|
4270
|
-
const prettierIgnore = await readText(
|
|
4271
|
-
|
|
4984
|
+
const prettierIgnore = await readText(path35.join(root, ".prettierignore")) ?? "";
|
|
4985
|
+
const lines = prettierIgnore.split("\n");
|
|
4986
|
+
const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
|
|
4987
|
+
if (missing.length > 0) {
|
|
4272
4988
|
flag(
|
|
4273
|
-
|
|
4989
|
+
`- .prettierignore: not protecting ${missing.join(", ")} (run \`haus apply --write\`)`,
|
|
4274
4990
|
'The formatter may reformat managed files and trigger false "modified locally" reports',
|
|
4275
4991
|
"haus apply --write"
|
|
4276
4992
|
);
|
|
4277
4993
|
} else {
|
|
4278
|
-
ok(
|
|
4994
|
+
ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
|
|
4279
4995
|
}
|
|
4280
4996
|
}
|
|
4281
4997
|
for (const finding of await auditDecisionsLayout(root)) {
|
|
@@ -4301,7 +5017,21 @@ async function runDoctor(options) {
|
|
|
4301
5017
|
ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
|
|
4302
5018
|
}
|
|
4303
5019
|
}
|
|
4304
|
-
|
|
5020
|
+
if (recommendation) {
|
|
5021
|
+
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
5022
|
+
const recommendedIds = new Set(
|
|
5023
|
+
recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
|
|
5024
|
+
);
|
|
5025
|
+
const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
|
|
5026
|
+
if (orphaned.length > 0) {
|
|
5027
|
+
suggest(
|
|
5028
|
+
`- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
|
|
5029
|
+
`${orphaned.length} installed item(s) are no longer recommended for this project`,
|
|
5030
|
+
"review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
|
|
5031
|
+
);
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
|
|
4305
5035
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
4306
5036
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
4307
5037
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -4517,7 +5247,7 @@ function matchesDenyToken(command, denyPhrase) {
|
|
|
4517
5247
|
if (denyPhrase === "sudo") {
|
|
4518
5248
|
return /(?:^|[|;&]\s*)sudo\b/i.test(command);
|
|
4519
5249
|
}
|
|
4520
|
-
const normalizedCommand = command.toLowerCase().replace(/\s+/g, " ").trim();
|
|
5250
|
+
const normalizedCommand = command.replace(/["'`]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
4521
5251
|
return normalizedCommand.includes(denyPhrase.toLowerCase());
|
|
4522
5252
|
}
|
|
4523
5253
|
function guardBash(command) {
|
|
@@ -4559,41 +5289,29 @@ async function runGuard(kind, _options) {
|
|
|
4559
5289
|
}
|
|
4560
5290
|
|
|
4561
5291
|
// src/commands/init.ts
|
|
4562
|
-
import
|
|
4563
|
-
import
|
|
4564
|
-
|
|
4565
|
-
// src/utils/prompts.ts
|
|
4566
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
4567
|
-
import readline from "node:readline/promises";
|
|
4568
|
-
async function ask(question) {
|
|
4569
|
-
const rl = readline.createInterface({ input, output });
|
|
4570
|
-
try {
|
|
4571
|
-
const answer = await rl.question(`${question}
|
|
4572
|
-
> `);
|
|
4573
|
-
return answer.trim();
|
|
4574
|
-
} finally {
|
|
4575
|
-
rl.close();
|
|
4576
|
-
}
|
|
4577
|
-
}
|
|
4578
|
-
async function confirm(question) {
|
|
4579
|
-
const answer = (await ask(`${question} [y/N]`)).toLowerCase();
|
|
4580
|
-
return answer === "y" || answer === "yes";
|
|
4581
|
-
}
|
|
5292
|
+
import path36 from "node:path";
|
|
5293
|
+
import fs29 from "fs-extra";
|
|
4582
5294
|
|
|
4583
5295
|
// src/recommender/git-signal.ts
|
|
4584
5296
|
init_exec();
|
|
4585
|
-
async function
|
|
4586
|
-
if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
|
|
5297
|
+
async function runGitLines(root, args) {
|
|
4587
5298
|
try {
|
|
4588
|
-
const result = await runGit(
|
|
4589
|
-
if (result.exitCode !== 0)
|
|
4590
|
-
|
|
4591
|
-
}
|
|
4592
|
-
return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean).sort();
|
|
5299
|
+
const result = await runGit(args, { cwd: root, timeout: 3e3 });
|
|
5300
|
+
if (result.exitCode !== 0) return [];
|
|
5301
|
+
return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean);
|
|
4593
5302
|
} catch {
|
|
4594
5303
|
return [];
|
|
4595
5304
|
}
|
|
4596
5305
|
}
|
|
5306
|
+
async function readChangedFiles(root) {
|
|
5307
|
+
if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
|
|
5308
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
5309
|
+
runGitLines(root, ["diff", "--name-only"]),
|
|
5310
|
+
runGitLines(root, ["diff", "--cached", "--name-only"]),
|
|
5311
|
+
runGitLines(root, ["ls-files", "--others", "--exclude-standard"])
|
|
5312
|
+
]);
|
|
5313
|
+
return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
|
|
5314
|
+
}
|
|
4597
5315
|
|
|
4598
5316
|
// src/recommender/policies.ts
|
|
4599
5317
|
var UNSUPPORTED = FORBIDDEN_TAGS;
|
|
@@ -4668,6 +5386,7 @@ async function recommend(root, context, opts = {}) {
|
|
|
4668
5386
|
const recommended = [];
|
|
4669
5387
|
const skipped = [];
|
|
4670
5388
|
const sourceTrust = new Map(sources.items.map((x) => [x.source, x.status]));
|
|
5389
|
+
const formerIds = new Set(items.flatMap((item) => normalizeFormerIds(item.formerIds)));
|
|
4671
5390
|
const changedFiles = await readChangedFiles(root);
|
|
4672
5391
|
const skip = (id, code, message, signal) => {
|
|
4673
5392
|
skipped.push({ id, reason: message, skipReasons: [{ code, message, signal }] });
|
|
@@ -4675,6 +5394,10 @@ async function recommend(root, context, opts = {}) {
|
|
|
4675
5394
|
const roleSignal = (name) => scannerRoles.has(name.toLowerCase()) ? `role:${name}` : `deep:role:${name}`;
|
|
4676
5395
|
const stackSignal = (name) => scannerStacks.has(name.toLowerCase()) ? `tag:${name}` : `deep:tag:${name}`;
|
|
4677
5396
|
for (const item of items) {
|
|
5397
|
+
if (formerIds.has(item.id)) {
|
|
5398
|
+
skip(item.id, "former-id", "Catalog id was renamed and cannot be newly recommended");
|
|
5399
|
+
continue;
|
|
5400
|
+
}
|
|
4678
5401
|
const normSource = typeof item.source === "string" ? item.source.trim() : "";
|
|
4679
5402
|
if (!normSource) {
|
|
4680
5403
|
skip(item.id, "invalid-source", "Item source is missing or empty");
|
|
@@ -4916,6 +5639,7 @@ function applyCoInstallSuppression(recommended, skipped) {
|
|
|
4916
5639
|
}
|
|
4917
5640
|
}
|
|
4918
5641
|
var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
|
|
5642
|
+
"former-id",
|
|
4919
5643
|
"unsupported-policy",
|
|
4920
5644
|
"deprecated",
|
|
4921
5645
|
"curated-not-approved",
|
|
@@ -4990,7 +5714,7 @@ function buildOptInEligible(items, skipped) {
|
|
|
4990
5714
|
return eligible;
|
|
4991
5715
|
}
|
|
4992
5716
|
|
|
4993
|
-
// src/
|
|
5717
|
+
// src/claude/setup-core.ts
|
|
4994
5718
|
async function runSetupCore(root, opts) {
|
|
4995
5719
|
const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
|
|
4996
5720
|
const say = quiet ? () => {
|
|
@@ -5083,8 +5807,8 @@ async function runSetupProject(options) {
|
|
|
5083
5807
|
// src/commands/init.ts
|
|
5084
5808
|
async function runInit(options) {
|
|
5085
5809
|
const root = process.cwd();
|
|
5086
|
-
const hausDir =
|
|
5087
|
-
const alreadyInit = await
|
|
5810
|
+
const hausDir = path36.join(root, ".haus-workflow");
|
|
5811
|
+
const alreadyInit = await fs29.pathExists(hausDir);
|
|
5088
5812
|
if (alreadyInit) {
|
|
5089
5813
|
log("Haus AI already initialized in this project.");
|
|
5090
5814
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -5096,8 +5820,8 @@ async function runInit(options) {
|
|
|
5096
5820
|
|
|
5097
5821
|
// src/install/apply.ts
|
|
5098
5822
|
import crypto2 from "node:crypto";
|
|
5099
|
-
import
|
|
5100
|
-
import
|
|
5823
|
+
import path37 from "node:path";
|
|
5824
|
+
import fs30 from "fs-extra";
|
|
5101
5825
|
|
|
5102
5826
|
// src/install/header.ts
|
|
5103
5827
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -5183,57 +5907,57 @@ function hashContent(content2) {
|
|
|
5183
5907
|
}
|
|
5184
5908
|
function sourceVersion() {
|
|
5185
5909
|
try {
|
|
5186
|
-
const pkgPath =
|
|
5187
|
-
const pkg = JSON.parse(
|
|
5910
|
+
const pkgPath = path37.join(packageRoot(), "package.json");
|
|
5911
|
+
const pkg = JSON.parse(fs30.readFileSync(pkgPath, "utf8"));
|
|
5188
5912
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
5189
5913
|
} catch {
|
|
5190
5914
|
return "haus@0.0.0";
|
|
5191
5915
|
}
|
|
5192
5916
|
}
|
|
5193
5917
|
function globalSrcDir() {
|
|
5194
|
-
return
|
|
5918
|
+
return path37.join(packageRoot(), "library", "global");
|
|
5195
5919
|
}
|
|
5196
5920
|
function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
5197
|
-
const skillDir =
|
|
5921
|
+
const skillDir = path37.join(skillsDir, skillName);
|
|
5198
5922
|
const entries = [];
|
|
5199
|
-
for (const rel of
|
|
5923
|
+
for (const rel of fs30.readdirSync(skillDir, { recursive: true })) {
|
|
5200
5924
|
if (rel === "SKILL.md") continue;
|
|
5201
|
-
const abs =
|
|
5202
|
-
if (!
|
|
5203
|
-
const posixRel = rel.split(
|
|
5925
|
+
const abs = path37.join(skillDir, rel);
|
|
5926
|
+
if (!fs30.statSync(abs).isFile()) continue;
|
|
5927
|
+
const posixRel = rel.split(path37.sep).join("/");
|
|
5204
5928
|
entries.push({
|
|
5205
5929
|
stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
|
|
5206
|
-
srcRelPath:
|
|
5207
|
-
destPath:
|
|
5930
|
+
srcRelPath: path37.join("library", "global", "skills", skillName, rel),
|
|
5931
|
+
destPath: path37.join(claudeDir, "skills", skillName, rel)
|
|
5208
5932
|
});
|
|
5209
5933
|
}
|
|
5210
5934
|
return entries;
|
|
5211
5935
|
}
|
|
5212
5936
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
5213
5937
|
const entries = [];
|
|
5214
|
-
const skillsDir =
|
|
5215
|
-
if (
|
|
5216
|
-
for (const skillName of
|
|
5217
|
-
const skillFile =
|
|
5218
|
-
if (
|
|
5938
|
+
const skillsDir = path37.join(srcDir, "skills");
|
|
5939
|
+
if (fs30.pathExistsSync(skillsDir)) {
|
|
5940
|
+
for (const skillName of fs30.readdirSync(skillsDir)) {
|
|
5941
|
+
const skillFile = path37.join(skillsDir, skillName, "SKILL.md");
|
|
5942
|
+
if (fs30.pathExistsSync(skillFile)) {
|
|
5219
5943
|
entries.push({
|
|
5220
5944
|
stableId: `skill.${skillName}`,
|
|
5221
|
-
srcRelPath:
|
|
5222
|
-
destPath:
|
|
5945
|
+
srcRelPath: path37.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
5946
|
+
destPath: path37.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
5223
5947
|
});
|
|
5224
5948
|
entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
|
|
5225
5949
|
}
|
|
5226
5950
|
}
|
|
5227
5951
|
}
|
|
5228
|
-
const commandsDir =
|
|
5229
|
-
if (
|
|
5230
|
-
for (const fileName of
|
|
5952
|
+
const commandsDir = path37.join(srcDir, "commands");
|
|
5953
|
+
if (fs30.pathExistsSync(commandsDir)) {
|
|
5954
|
+
for (const fileName of fs30.readdirSync(commandsDir)) {
|
|
5231
5955
|
if (!fileName.endsWith(".md")) continue;
|
|
5232
5956
|
const commandName = fileName.slice(0, -".md".length);
|
|
5233
5957
|
entries.push({
|
|
5234
5958
|
stableId: `command.${commandName}`,
|
|
5235
|
-
srcRelPath:
|
|
5236
|
-
destPath:
|
|
5959
|
+
srcRelPath: path37.join("library", "global", "commands", fileName),
|
|
5960
|
+
destPath: path37.join(claudeDir, "commands", fileName)
|
|
5237
5961
|
});
|
|
5238
5962
|
}
|
|
5239
5963
|
}
|
|
@@ -5259,7 +5983,7 @@ async function applyInstall(options = {}) {
|
|
|
5259
5983
|
};
|
|
5260
5984
|
const manifestFiles = [];
|
|
5261
5985
|
for (const entry of sourceFiles) {
|
|
5262
|
-
const srcPath =
|
|
5986
|
+
const srcPath = path37.join(packageRoot(), entry.srcRelPath);
|
|
5263
5987
|
const rawContent = await readText(srcPath);
|
|
5264
5988
|
if (rawContent === void 0) {
|
|
5265
5989
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
@@ -5279,7 +6003,7 @@ async function applyInstall(options = {}) {
|
|
|
5279
6003
|
}
|
|
5280
6004
|
continue;
|
|
5281
6005
|
}
|
|
5282
|
-
const destExists =
|
|
6006
|
+
const destExists = fs30.pathExistsSync(entry.destPath);
|
|
5283
6007
|
if (destExists) {
|
|
5284
6008
|
const currentContent = await readText(entry.destPath);
|
|
5285
6009
|
if (currentContent !== void 0) {
|
|
@@ -5319,7 +6043,7 @@ async function applyInstall(options = {}) {
|
|
|
5319
6043
|
}
|
|
5320
6044
|
let mergedSettings;
|
|
5321
6045
|
if (!check) {
|
|
5322
|
-
const fragmentPath =
|
|
6046
|
+
const fragmentPath = path37.join(srcDir, "settings-fragments", "hooks.json");
|
|
5323
6047
|
const fragments = await loadHooksFragment(fragmentPath);
|
|
5324
6048
|
const settings = await readSettings();
|
|
5325
6049
|
const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
|
|
@@ -5333,13 +6057,13 @@ async function applyInstall(options = {}) {
|
|
|
5333
6057
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
5334
6058
|
for (const entry of existingManifest.files) {
|
|
5335
6059
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
5336
|
-
if (!
|
|
6060
|
+
if (!fs30.pathExistsSync(entry.destPath)) continue;
|
|
5337
6061
|
const content2 = await readText(entry.destPath);
|
|
5338
6062
|
if (!content2) continue;
|
|
5339
6063
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
5340
6064
|
const currentHash = hashContent(content2);
|
|
5341
6065
|
if (hasHeader && currentHash === entry.hash) {
|
|
5342
|
-
if (!dryRun) await
|
|
6066
|
+
if (!dryRun) await fs30.remove(entry.destPath);
|
|
5343
6067
|
result.deleted.push(entry.destPath);
|
|
5344
6068
|
} else {
|
|
5345
6069
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -5508,8 +6232,8 @@ async function runRefresh() {
|
|
|
5508
6232
|
}
|
|
5509
6233
|
|
|
5510
6234
|
// src/install/scaffold.ts
|
|
5511
|
-
import
|
|
5512
|
-
import
|
|
6235
|
+
import path38 from "node:path";
|
|
6236
|
+
import fs31 from "fs-extra";
|
|
5513
6237
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
5514
6238
|
const scaffolded = [];
|
|
5515
6239
|
const skipped = [];
|
|
@@ -5522,27 +6246,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
5522
6246
|
skipped.push(name);
|
|
5523
6247
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
5524
6248
|
};
|
|
5525
|
-
const realRoot = await
|
|
6249
|
+
const realRoot = await fs31.realpath(catalogRoot).catch(() => null);
|
|
5526
6250
|
for (const item of items) {
|
|
5527
6251
|
if (item.type !== "config") continue;
|
|
5528
|
-
const sourcePath =
|
|
5529
|
-
const rel =
|
|
5530
|
-
const escapes = rel === "" ||
|
|
6252
|
+
const sourcePath = path38.resolve(catalogRoot, item.path);
|
|
6253
|
+
const rel = path38.relative(catalogRoot, sourcePath);
|
|
6254
|
+
const escapes = rel === "" || path38.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
|
|
5531
6255
|
if (escapes) {
|
|
5532
6256
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
5533
6257
|
continue;
|
|
5534
6258
|
}
|
|
5535
|
-
const realSource = await
|
|
6259
|
+
const realSource = await fs31.realpath(sourcePath).catch(() => null);
|
|
5536
6260
|
if (!realRoot || !realSource) {
|
|
5537
6261
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
5538
6262
|
continue;
|
|
5539
6263
|
}
|
|
5540
|
-
const realRel =
|
|
5541
|
-
if (realRel !== "" && (
|
|
6264
|
+
const realRel = path38.relative(realRoot, realSource);
|
|
6265
|
+
if (realRel !== "" && (path38.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
|
|
5542
6266
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
5543
6267
|
continue;
|
|
5544
6268
|
}
|
|
5545
|
-
const stat = await
|
|
6269
|
+
const stat = await fs31.lstat(sourcePath).catch(() => null);
|
|
5546
6270
|
if (!stat) {
|
|
5547
6271
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
5548
6272
|
continue;
|
|
@@ -5552,14 +6276,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
5552
6276
|
continue;
|
|
5553
6277
|
}
|
|
5554
6278
|
if (stat.isFile()) {
|
|
5555
|
-
const filename =
|
|
5556
|
-
const dest =
|
|
6279
|
+
const filename = path38.basename(sourcePath);
|
|
6280
|
+
const dest = path38.join(projectRoot, filename);
|
|
5557
6281
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
5558
6282
|
} else if (stat.isDirectory()) {
|
|
5559
|
-
const entries = await
|
|
6283
|
+
const entries = await fs31.readdir(sourcePath);
|
|
5560
6284
|
for (const entry of entries) {
|
|
5561
|
-
const src =
|
|
5562
|
-
const dest =
|
|
6285
|
+
const src = path38.join(sourcePath, entry);
|
|
6286
|
+
const dest = path38.join(projectRoot, entry);
|
|
5563
6287
|
record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
|
|
5564
6288
|
}
|
|
5565
6289
|
}
|
|
@@ -5567,42 +6291,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
5567
6291
|
return { scaffolded, skipped, skippedExisting };
|
|
5568
6292
|
}
|
|
5569
6293
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
5570
|
-
const destDisplay =
|
|
5571
|
-
const srcStat = await
|
|
6294
|
+
const destDisplay = path38.relative(projectRoot, dest) || path38.basename(dest);
|
|
6295
|
+
const srcStat = await fs31.lstat(src).catch(() => null);
|
|
5572
6296
|
if (!srcStat) {
|
|
5573
|
-
warn(`Skipping ${
|
|
6297
|
+
warn(`Skipping ${path38.basename(src)}: source not found`);
|
|
5574
6298
|
return "skipped-refused";
|
|
5575
6299
|
}
|
|
5576
6300
|
if (srcStat.isSymbolicLink()) {
|
|
5577
|
-
warn(`Skipping ${
|
|
6301
|
+
warn(`Skipping ${path38.basename(src)}: source is a symlink`);
|
|
5578
6302
|
return "skipped-refused";
|
|
5579
6303
|
}
|
|
5580
|
-
const exists = await
|
|
6304
|
+
const exists = await fs31.pathExists(dest);
|
|
5581
6305
|
if (exists && !opts.force) {
|
|
5582
6306
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
5583
6307
|
return "skipped-exists";
|
|
5584
6308
|
}
|
|
5585
6309
|
if (opts.dryRun) {
|
|
5586
|
-
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${
|
|
6310
|
+
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path38.basename(dest)} (${itemId})`);
|
|
5587
6311
|
return "scaffolded";
|
|
5588
6312
|
}
|
|
5589
6313
|
if (exists && opts.force) {
|
|
5590
|
-
await
|
|
6314
|
+
await fs31.remove(dest);
|
|
5591
6315
|
}
|
|
5592
|
-
await
|
|
5593
|
-
await
|
|
6316
|
+
await fs31.ensureDir(path38.dirname(dest));
|
|
6317
|
+
await fs31.copy(src, dest, {
|
|
5594
6318
|
overwrite: true,
|
|
5595
6319
|
dereference: false,
|
|
5596
6320
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
5597
6321
|
filter: (s) => {
|
|
5598
6322
|
try {
|
|
5599
|
-
return !
|
|
6323
|
+
return !fs31.lstatSync(s).isSymbolicLink();
|
|
5600
6324
|
} catch {
|
|
5601
6325
|
return false;
|
|
5602
6326
|
}
|
|
5603
6327
|
}
|
|
5604
6328
|
});
|
|
5605
|
-
log(`${exists ? "Overwrote" : "Created"} ${
|
|
6329
|
+
log(`${exists ? "Overwrote" : "Created"} ${path38.basename(dest)} (${itemId})`);
|
|
5606
6330
|
return "scaffolded";
|
|
5607
6331
|
}
|
|
5608
6332
|
|
|
@@ -5659,8 +6383,8 @@ async function runScan(options) {
|
|
|
5659
6383
|
}
|
|
5660
6384
|
|
|
5661
6385
|
// src/commands/undo.ts
|
|
5662
|
-
import
|
|
5663
|
-
import
|
|
6386
|
+
import path39 from "node:path";
|
|
6387
|
+
import fs32 from "fs-extra";
|
|
5664
6388
|
|
|
5665
6389
|
// src/claude/managed-paths.ts
|
|
5666
6390
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
|
|
@@ -5672,67 +6396,85 @@ function coreManagedAbsolutePaths(root) {
|
|
|
5672
6396
|
}
|
|
5673
6397
|
|
|
5674
6398
|
// src/commands/undo.ts
|
|
5675
|
-
async function
|
|
5676
|
-
const
|
|
6399
|
+
async function collectCoreManagedPaths(root) {
|
|
6400
|
+
const existing = [];
|
|
6401
|
+
for (const abs of coreManagedAbsolutePaths(root)) {
|
|
6402
|
+
if (await fs32.pathExists(abs)) existing.push(abs);
|
|
6403
|
+
}
|
|
6404
|
+
return existing;
|
|
6405
|
+
}
|
|
6406
|
+
async function collectLockTrackedPaths(root) {
|
|
5677
6407
|
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
6408
|
+
const removable = [];
|
|
6409
|
+
const preserved = [];
|
|
5678
6410
|
for (const row of lock ?? []) {
|
|
5679
|
-
|
|
5680
|
-
|
|
6411
|
+
const relPaths = row.paths ?? [];
|
|
6412
|
+
const existingRel = [];
|
|
6413
|
+
for (const rel of relPaths) {
|
|
6414
|
+
if (await fs32.pathExists(path39.resolve(root, rel))) existingRel.push(rel);
|
|
6415
|
+
}
|
|
6416
|
+
if (existingRel.length === 0) continue;
|
|
6417
|
+
if (row.hash === void 0) {
|
|
6418
|
+
removable.push(...existingRel.map((rel) => path39.resolve(root, rel)));
|
|
6419
|
+
continue;
|
|
6420
|
+
}
|
|
6421
|
+
const currentHash = await hashInstalledPaths(root, existingRel);
|
|
6422
|
+
if (currentHash === row.hash) {
|
|
6423
|
+
removable.push(...existingRel.map((rel) => path39.resolve(root, rel)));
|
|
6424
|
+
} else {
|
|
6425
|
+
preserved.push(...existingRel.map((rel) => path39.resolve(root, rel)));
|
|
6426
|
+
log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
|
|
5681
6427
|
}
|
|
5682
6428
|
}
|
|
5683
|
-
|
|
5684
|
-
for (const abs of paths) {
|
|
5685
|
-
if (await fs26.pathExists(abs)) existing.push(abs);
|
|
5686
|
-
}
|
|
5687
|
-
return existing;
|
|
6429
|
+
return { removable, preserved };
|
|
5688
6430
|
}
|
|
5689
6431
|
async function settingsHasHausContent(root) {
|
|
5690
6432
|
const settingsPath = claudePath(root, "settings.json");
|
|
5691
|
-
if (!await
|
|
6433
|
+
if (!await fs32.pathExists(settingsPath)) return false;
|
|
5692
6434
|
const settings = await readProjectSettings(root);
|
|
5693
6435
|
return settings._haus != null;
|
|
5694
6436
|
}
|
|
5695
6437
|
async function claudeMdHasHausBlock(root) {
|
|
5696
|
-
const filePath =
|
|
5697
|
-
if (!await
|
|
5698
|
-
const text = await
|
|
6438
|
+
const filePath = path39.join(root, "CLAUDE.md");
|
|
6439
|
+
if (!await fs32.pathExists(filePath)) return false;
|
|
6440
|
+
const text = await fs32.readFile(filePath, "utf8");
|
|
5699
6441
|
return text.includes(BLOCK_BEGIN);
|
|
5700
6442
|
}
|
|
5701
6443
|
async function stripProjectSettings(root) {
|
|
5702
6444
|
const settingsPath = claudePath(root, "settings.json");
|
|
5703
|
-
if (!await
|
|
6445
|
+
if (!await fs32.pathExists(settingsPath)) return false;
|
|
5704
6446
|
let settings = await readProjectSettings(root);
|
|
5705
6447
|
settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
5706
6448
|
const hasContent = Object.keys(settings).length > 0;
|
|
5707
6449
|
if (hasContent) {
|
|
5708
6450
|
await writeProjectSettings(root, settings);
|
|
5709
|
-
log(`Stripped haus rules from ${
|
|
6451
|
+
log(`Stripped haus rules from ${path39.relative(root, settingsPath)} (user settings preserved).`);
|
|
5710
6452
|
return true;
|
|
5711
6453
|
}
|
|
5712
|
-
await
|
|
5713
|
-
log(`Removed ${
|
|
6454
|
+
await fs32.remove(settingsPath);
|
|
6455
|
+
log(`Removed ${path39.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
5714
6456
|
return true;
|
|
5715
6457
|
}
|
|
5716
6458
|
async function stripRootClaudeMd(root) {
|
|
5717
|
-
const filePath =
|
|
5718
|
-
if (!await
|
|
5719
|
-
const prev = await
|
|
6459
|
+
const filePath = path39.join(root, "CLAUDE.md");
|
|
6460
|
+
if (!await fs32.pathExists(filePath)) return false;
|
|
6461
|
+
const prev = await fs32.readFile(filePath, "utf8");
|
|
5720
6462
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
5721
6463
|
const next = stripHausBlock(prev);
|
|
5722
6464
|
if (next.length === 0) {
|
|
5723
|
-
await
|
|
6465
|
+
await fs32.remove(filePath);
|
|
5724
6466
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
5725
6467
|
} else {
|
|
5726
|
-
await
|
|
6468
|
+
await fs32.writeFile(filePath, next, "utf8");
|
|
5727
6469
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
5728
6470
|
}
|
|
5729
6471
|
return true;
|
|
5730
6472
|
}
|
|
5731
6473
|
async function pruneDirIfEmpty(dir) {
|
|
5732
|
-
if (!await
|
|
6474
|
+
if (!await fs32.pathExists(dir)) return;
|
|
5733
6475
|
try {
|
|
5734
|
-
const entries = await
|
|
5735
|
-
if (entries.length === 0) await
|
|
6476
|
+
const entries = await fs32.readdir(dir);
|
|
6477
|
+
if (entries.length === 0) await fs32.remove(dir);
|
|
5736
6478
|
} catch {
|
|
5737
6479
|
}
|
|
5738
6480
|
}
|
|
@@ -5741,27 +6483,34 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
|
5741
6483
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
5742
6484
|
const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
|
|
5743
6485
|
for (const abs of managedAbsPaths) {
|
|
5744
|
-
if (!await
|
|
5745
|
-
const rel =
|
|
5746
|
-
const backupPath =
|
|
5747
|
-
await
|
|
5748
|
-
await
|
|
6486
|
+
if (!await fs32.pathExists(abs)) continue;
|
|
6487
|
+
const rel = path39.relative(root, abs);
|
|
6488
|
+
const backupPath = path39.join(backupRoot, rel);
|
|
6489
|
+
await fs32.ensureDir(path39.dirname(backupPath));
|
|
6490
|
+
await fs32.copy(abs, backupPath);
|
|
5749
6491
|
}
|
|
5750
|
-
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${
|
|
6492
|
+
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path39.relative(root, backupRoot)}.`);
|
|
5751
6493
|
}
|
|
5752
6494
|
async function runUndo(options) {
|
|
5753
|
-
const root = process.cwd();
|
|
5754
|
-
const
|
|
6495
|
+
const root = options.root ?? process.cwd();
|
|
6496
|
+
const coreManaged = await collectCoreManagedPaths(root);
|
|
6497
|
+
const { removable: lockRemovable, preserved: lockPreserved } = await collectLockTrackedPaths(root);
|
|
6498
|
+
const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockRemovable])];
|
|
5755
6499
|
const stripSettings = await settingsHasHausContent(root);
|
|
5756
6500
|
const stripClaudeMd = await claudeMdHasHausBlock(root);
|
|
5757
6501
|
if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
|
|
5758
6502
|
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
5759
6503
|
return;
|
|
5760
6504
|
}
|
|
5761
|
-
const relTargets = managed.map((p) =>
|
|
6505
|
+
const relTargets = managed.map((p) => path39.relative(root, p));
|
|
5762
6506
|
const summaryParts = [...relTargets];
|
|
5763
6507
|
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
5764
6508
|
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
6509
|
+
if (lockPreserved.length > 0) {
|
|
6510
|
+
summaryParts.push(
|
|
6511
|
+
`(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
|
|
6512
|
+
);
|
|
6513
|
+
}
|
|
5765
6514
|
if (!options.yes) {
|
|
5766
6515
|
const ok = await confirm(
|
|
5767
6516
|
`Remove haus-managed files?
|
|
@@ -5775,9 +6524,9 @@ User-owned .claude/ files will be preserved.`
|
|
|
5775
6524
|
}
|
|
5776
6525
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
5777
6526
|
for (const abs of managed) {
|
|
5778
|
-
if (!await
|
|
5779
|
-
await
|
|
5780
|
-
log(`Removed ${
|
|
6527
|
+
if (!await fs32.pathExists(abs)) continue;
|
|
6528
|
+
await fs32.remove(abs);
|
|
6529
|
+
log(`Removed ${path39.relative(root, abs)}`);
|
|
5781
6530
|
}
|
|
5782
6531
|
if (stripSettings) await stripProjectSettings(root);
|
|
5783
6532
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
@@ -5788,8 +6537,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
5788
6537
|
|
|
5789
6538
|
// src/install/uninstall.ts
|
|
5790
6539
|
import crypto3 from "node:crypto";
|
|
5791
|
-
import
|
|
5792
|
-
import
|
|
6540
|
+
import path40 from "node:path";
|
|
6541
|
+
import fs33 from "fs-extra";
|
|
5793
6542
|
async function runUninstall(options = {}) {
|
|
5794
6543
|
const { force = false } = options;
|
|
5795
6544
|
const manifest = await readManifest();
|
|
@@ -5799,7 +6548,7 @@ async function runUninstall(options = {}) {
|
|
|
5799
6548
|
return result;
|
|
5800
6549
|
}
|
|
5801
6550
|
for (const entry of manifest.files) {
|
|
5802
|
-
const exists =
|
|
6551
|
+
const exists = fs33.pathExistsSync(entry.destPath);
|
|
5803
6552
|
if (!exists) continue;
|
|
5804
6553
|
const content2 = await readText(entry.destPath);
|
|
5805
6554
|
if (content2 === void 0) continue;
|
|
@@ -5817,23 +6566,23 @@ async function runUninstall(options = {}) {
|
|
|
5817
6566
|
result.skipped.push(entry.destPath);
|
|
5818
6567
|
continue;
|
|
5819
6568
|
}
|
|
5820
|
-
await
|
|
5821
|
-
await pruneEmptyDir(
|
|
6569
|
+
await fs33.remove(entry.destPath);
|
|
6570
|
+
await pruneEmptyDir(path40.dirname(entry.destPath));
|
|
5822
6571
|
result.deleted.push(entry.destPath);
|
|
5823
6572
|
}
|
|
5824
6573
|
const settings = await readSettings();
|
|
5825
6574
|
const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
5826
6575
|
await writeSettings(stripped);
|
|
5827
6576
|
result.hooksStripped = true;
|
|
5828
|
-
const hausDir =
|
|
6577
|
+
const hausDir = path40.join(globalClaudeDir(), "haus");
|
|
5829
6578
|
const manifestPath2 = hausManifestPath();
|
|
5830
|
-
if (
|
|
5831
|
-
await
|
|
6579
|
+
if (fs33.pathExistsSync(manifestPath2)) {
|
|
6580
|
+
await fs33.remove(manifestPath2);
|
|
5832
6581
|
}
|
|
5833
|
-
if (
|
|
6582
|
+
if (fs33.pathExistsSync(hausDir)) {
|
|
5834
6583
|
try {
|
|
5835
|
-
const remaining = await
|
|
5836
|
-
if (remaining.length === 0) await
|
|
6584
|
+
const remaining = await fs33.readdir(hausDir);
|
|
6585
|
+
if (remaining.length === 0) await fs33.remove(hausDir);
|
|
5837
6586
|
} catch {
|
|
5838
6587
|
}
|
|
5839
6588
|
}
|
|
@@ -5866,12 +6615,12 @@ async function runUninstallCommand(options) {
|
|
|
5866
6615
|
}
|
|
5867
6616
|
|
|
5868
6617
|
// src/commands/update.ts
|
|
5869
|
-
import
|
|
6618
|
+
import path42 from "node:path";
|
|
5870
6619
|
|
|
5871
6620
|
// src/claude/refresh-project.ts
|
|
5872
|
-
import
|
|
6621
|
+
import fs34 from "fs-extra";
|
|
5873
6622
|
async function isHausProject(root) {
|
|
5874
|
-
if (await
|
|
6623
|
+
if (await fs34.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
5875
6624
|
const result = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
5876
6625
|
if (result.status === "ok") return result.value._haus != null;
|
|
5877
6626
|
return false;
|
|
@@ -5906,7 +6655,7 @@ function summarizeLockDiff(before, after) {
|
|
|
5906
6655
|
|
|
5907
6656
|
// src/update/lockfile.ts
|
|
5908
6657
|
import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
|
|
5909
|
-
import
|
|
6658
|
+
import path41 from "node:path";
|
|
5910
6659
|
async function readLockSummary(root) {
|
|
5911
6660
|
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
5912
6661
|
const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
|
|
@@ -5917,7 +6666,7 @@ async function checkLock(root) {
|
|
|
5917
6666
|
const hasValidVersions = lock.every(
|
|
5918
6667
|
(item) => !item.version || normalizeVersion(item.version) !== null
|
|
5919
6668
|
);
|
|
5920
|
-
const catalogRef = lock
|
|
6669
|
+
const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
|
|
5921
6670
|
const drift = [];
|
|
5922
6671
|
for (const item of lock) {
|
|
5923
6672
|
if (!item.hash) continue;
|
|
@@ -5942,7 +6691,7 @@ async function applyLock(root) {
|
|
|
5942
6691
|
try {
|
|
5943
6692
|
const backupDir = hausPath(root, "backups");
|
|
5944
6693
|
await mkdir(backupDir, { recursive: true });
|
|
5945
|
-
await copyFile(lockPath,
|
|
6694
|
+
await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
5946
6695
|
} catch {
|
|
5947
6696
|
}
|
|
5948
6697
|
const enriched = await Promise.all(
|
|
@@ -5964,7 +6713,7 @@ function diffLock(before, after) {
|
|
|
5964
6713
|
}
|
|
5965
6714
|
async function hasLocalOverrides(root) {
|
|
5966
6715
|
try {
|
|
5967
|
-
await readFile2(
|
|
6716
|
+
await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
|
|
5968
6717
|
return true;
|
|
5969
6718
|
} catch {
|
|
5970
6719
|
return false;
|
|
@@ -5980,23 +6729,28 @@ async function runUpdate(options) {
|
|
|
5980
6729
|
return;
|
|
5981
6730
|
}
|
|
5982
6731
|
if (options.check) {
|
|
5983
|
-
const pkgJson2 = await readJson(
|
|
6732
|
+
const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
|
|
5984
6733
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
5985
|
-
const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
|
|
5986
|
-
checkLock(root),
|
|
6734
|
+
const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
|
|
6735
|
+
options.fast ? fastLockStatus(root) : checkLock(root),
|
|
5987
6736
|
fetchNpmVersionStatus(currentVersion2),
|
|
5988
6737
|
fetchLatestCatalogTag(),
|
|
5989
|
-
detectGlobalInstallDrift()
|
|
6738
|
+
options.fast ? Promise.resolve(null) : detectGlobalInstallDrift(),
|
|
6739
|
+
loadCatalog(root),
|
|
6740
|
+
readJson(hausPath(root, "haus.lock.json"))
|
|
5990
6741
|
]);
|
|
5991
|
-
const
|
|
5992
|
-
const
|
|
6742
|
+
const formerIdMigrations = findFormerIdMigrations(lockItems ?? [], catalogItems);
|
|
6743
|
+
const installedRef = options.fast ? status.catalogRef : status.catalogRef ?? "main";
|
|
6744
|
+
const catalogRefBehind = installedRef !== null && latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
|
|
5993
6745
|
log(
|
|
5994
6746
|
JSON.stringify(
|
|
5995
6747
|
{
|
|
5996
6748
|
...status,
|
|
6749
|
+
checkMode: options.fast ? "fast" : "full",
|
|
5997
6750
|
installedCatalogRef: installedRef,
|
|
5998
6751
|
latestCatalogTag,
|
|
5999
6752
|
catalogRefBehind,
|
|
6753
|
+
formerIdMigrations,
|
|
6000
6754
|
globalInstallDrift,
|
|
6001
6755
|
localOverrides: await hasLocalOverrides(root),
|
|
6002
6756
|
summary: diffGeneratedFiles(),
|
|
@@ -6006,10 +6760,11 @@ async function runUpdate(options) {
|
|
|
6006
6760
|
2
|
|
6007
6761
|
)
|
|
6008
6762
|
);
|
|
6009
|
-
|
|
6763
|
+
const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
|
|
6764
|
+
if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
|
|
6010
6765
|
return;
|
|
6011
6766
|
}
|
|
6012
|
-
const pkgJson = await readJson(
|
|
6767
|
+
const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
|
|
6013
6768
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
6014
6769
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
6015
6770
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -6048,7 +6803,7 @@ async function runFromHookCheck(root) {
|
|
|
6048
6803
|
try {
|
|
6049
6804
|
const summary = await readLockSummary(root);
|
|
6050
6805
|
if (summary.count === 0) return;
|
|
6051
|
-
const pkgJson = await readJson(
|
|
6806
|
+
const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
|
|
6052
6807
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
6053
6808
|
const [npmVersion, latestCatalogTag] = await Promise.all([
|
|
6054
6809
|
fetchNpmVersionStatus(currentVersion),
|
|
@@ -6114,19 +6869,18 @@ async function detectGlobalInstallDrift() {
|
|
|
6114
6869
|
return null;
|
|
6115
6870
|
}
|
|
6116
6871
|
}
|
|
6872
|
+
async function fastLockStatus(root) {
|
|
6873
|
+
const summary = await readLockSummary(root);
|
|
6874
|
+
return { count: summary.count, catalogRef: summary.catalogRef, drift: [], driftCount: 0 };
|
|
6875
|
+
}
|
|
6117
6876
|
|
|
6118
6877
|
// src/commands/validate-catalog.ts
|
|
6119
|
-
import
|
|
6878
|
+
import path44 from "node:path";
|
|
6120
6879
|
|
|
6121
6880
|
// src/catalog/validate-core.ts
|
|
6122
|
-
import
|
|
6123
|
-
import
|
|
6881
|
+
import fs35 from "node:fs";
|
|
6882
|
+
import path43 from "node:path";
|
|
6124
6883
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
6125
|
-
function isSafeCatalogPath2(itemPath) {
|
|
6126
|
-
if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
6127
|
-
const normalized = path36.normalize(itemPath);
|
|
6128
|
-
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
6129
|
-
}
|
|
6130
6884
|
function auditForbiddenStacks(items) {
|
|
6131
6885
|
const failures = [];
|
|
6132
6886
|
for (const item of items) {
|
|
@@ -6206,7 +6960,7 @@ function auditManifestStructure(manifestVersion, items) {
|
|
|
6206
6960
|
if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command" || item.type === "config") {
|
|
6207
6961
|
if (!item.path) {
|
|
6208
6962
|
failures.push(`${item.id}: missing path`);
|
|
6209
|
-
} else if (!
|
|
6963
|
+
} else if (!isSafeCatalogPath(item.path)) {
|
|
6210
6964
|
failures.push(`${item.id}: path "${item.path}" is not a safe relative path`);
|
|
6211
6965
|
} else {
|
|
6212
6966
|
const norm = item.path.replace(/\\/g, "/");
|
|
@@ -6235,14 +6989,14 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
6235
6989
|
}
|
|
6236
6990
|
function readForAudit(absPath, label, failures) {
|
|
6237
6991
|
try {
|
|
6238
|
-
return
|
|
6992
|
+
return fs35.readFileSync(absPath, "utf8");
|
|
6239
6993
|
} catch (err) {
|
|
6240
6994
|
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
6241
6995
|
return null;
|
|
6242
6996
|
}
|
|
6243
6997
|
}
|
|
6244
6998
|
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
6245
|
-
const rel =
|
|
6999
|
+
const rel = path43.relative(manifestDir, absPath);
|
|
6246
7000
|
const failures = [];
|
|
6247
7001
|
const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
|
|
6248
7002
|
if (text === null) return failures;
|
|
@@ -6263,48 +7017,48 @@ function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
|
6263
7017
|
function auditShippedFiles(manifestDir, items) {
|
|
6264
7018
|
const failures = [];
|
|
6265
7019
|
for (const item of items) {
|
|
6266
|
-
if (!item.path || !
|
|
6267
|
-
const absPath =
|
|
7020
|
+
if (!item.path || !isSafeCatalogPath(item.path)) continue;
|
|
7021
|
+
const absPath = path43.join(manifestDir, item.path);
|
|
6268
7022
|
if (item.type === "skill") {
|
|
6269
|
-
const skillMd =
|
|
6270
|
-
if (!
|
|
6271
|
-
failures.push(`${item.id}: missing ${
|
|
7023
|
+
const skillMd = path43.join(absPath, "SKILL.md");
|
|
7024
|
+
if (!fs35.existsSync(skillMd)) {
|
|
7025
|
+
failures.push(`${item.id}: missing ${path43.relative(manifestDir, skillMd)}`);
|
|
6272
7026
|
continue;
|
|
6273
7027
|
}
|
|
6274
7028
|
const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
|
|
6275
7029
|
if (text === null) continue;
|
|
6276
7030
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
|
|
6277
7031
|
failures.push(
|
|
6278
|
-
...auditForbiddenTagsInText(text, `${item.id}: ${
|
|
7032
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path43.relative(manifestDir, skillMd)}`)
|
|
6279
7033
|
);
|
|
6280
7034
|
} else if (item.type === "agent") {
|
|
6281
|
-
if (!
|
|
7035
|
+
if (!fs35.existsSync(absPath)) {
|
|
6282
7036
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
6283
7037
|
continue;
|
|
6284
7038
|
}
|
|
6285
|
-
const rel =
|
|
7039
|
+
const rel = path43.relative(manifestDir, absPath);
|
|
6286
7040
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
6287
7041
|
if (text === null) continue;
|
|
6288
7042
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
6289
7043
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
6290
7044
|
} else if (item.type === "template") {
|
|
6291
|
-
if (!
|
|
7045
|
+
if (!fs35.existsSync(absPath)) {
|
|
6292
7046
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
6293
7047
|
continue;
|
|
6294
7048
|
}
|
|
6295
7049
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
6296
7050
|
} else if (item.type === "command") {
|
|
6297
|
-
if (!
|
|
7051
|
+
if (!fs35.existsSync(absPath)) {
|
|
6298
7052
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
6299
7053
|
continue;
|
|
6300
7054
|
}
|
|
6301
|
-
const rel =
|
|
7055
|
+
const rel = path43.relative(manifestDir, absPath);
|
|
6302
7056
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
6303
7057
|
if (text === null) continue;
|
|
6304
7058
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
6305
7059
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
6306
7060
|
} else if (item.type === "config") {
|
|
6307
|
-
if (!
|
|
7061
|
+
if (!fs35.existsSync(absPath)) {
|
|
6308
7062
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
6309
7063
|
}
|
|
6310
7064
|
}
|
|
@@ -6320,7 +7074,7 @@ var DIR_ITEM_TYPE = {
|
|
|
6320
7074
|
function walkMd(dir, fn, failures) {
|
|
6321
7075
|
let entries;
|
|
6322
7076
|
try {
|
|
6323
|
-
entries =
|
|
7077
|
+
entries = fs35.readdirSync(dir, { withFileTypes: true });
|
|
6324
7078
|
} catch (err) {
|
|
6325
7079
|
failures.push(
|
|
6326
7080
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -6328,7 +7082,7 @@ function walkMd(dir, fn, failures) {
|
|
|
6328
7082
|
return;
|
|
6329
7083
|
}
|
|
6330
7084
|
for (const entry of entries) {
|
|
6331
|
-
const full =
|
|
7085
|
+
const full = path43.join(dir, entry.name);
|
|
6332
7086
|
if (entry.isDirectory()) walkMd(full, fn, failures);
|
|
6333
7087
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
6334
7088
|
}
|
|
@@ -6337,13 +7091,13 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
6337
7091
|
const failures = [];
|
|
6338
7092
|
const pathSourceMap = buildItemPathSourceMap(items);
|
|
6339
7093
|
for (const dir of ["skills", "agents", "templates", "commands"]) {
|
|
6340
|
-
const abs =
|
|
6341
|
-
if (!
|
|
7094
|
+
const abs = path43.join(manifestDir, dir);
|
|
7095
|
+
if (!fs35.existsSync(abs)) continue;
|
|
6342
7096
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
6343
7097
|
walkMd(
|
|
6344
7098
|
abs,
|
|
6345
7099
|
(file) => {
|
|
6346
|
-
const rel =
|
|
7100
|
+
const rel = path43.relative(manifestDir, file);
|
|
6347
7101
|
const text = readForAudit(file, rel, failures);
|
|
6348
7102
|
if (text === null) return;
|
|
6349
7103
|
const norm = rel.replace(/\\/g, "/");
|
|
@@ -6368,6 +7122,7 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
6368
7122
|
function validateCatalogData(manifestDir, manifestVersion, items) {
|
|
6369
7123
|
const failures = [
|
|
6370
7124
|
...auditManifestStructure(manifestVersion, items),
|
|
7125
|
+
...validateFormerIds(items),
|
|
6371
7126
|
...auditOptInMetadata(items),
|
|
6372
7127
|
...auditForbiddenStacks(items),
|
|
6373
7128
|
...auditShippedFiles(manifestDir, items),
|
|
@@ -6384,8 +7139,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
6384
7139
|
process.exitCode = 1;
|
|
6385
7140
|
return;
|
|
6386
7141
|
}
|
|
6387
|
-
const abs =
|
|
6388
|
-
const manifestDir =
|
|
7142
|
+
const abs = path44.resolve(process.cwd(), manifestPath2);
|
|
7143
|
+
const manifestDir = path44.dirname(abs);
|
|
6389
7144
|
const data = await readJson(abs);
|
|
6390
7145
|
if (!data?.items) {
|
|
6391
7146
|
error(`Could not read catalog manifest at ${abs}`);
|
|
@@ -6402,8 +7157,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
6402
7157
|
}
|
|
6403
7158
|
|
|
6404
7159
|
// src/commands/workspace.ts
|
|
6405
|
-
import { existsSync as
|
|
6406
|
-
import
|
|
7160
|
+
import { existsSync as existsSync6, statSync as statSync3 } from "node:fs";
|
|
7161
|
+
import path52 from "node:path";
|
|
6407
7162
|
|
|
6408
7163
|
// src/commands/workspace/aggregate.ts
|
|
6409
7164
|
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
@@ -6457,7 +7212,7 @@ ${summaries.map(
|
|
|
6457
7212
|
}
|
|
6458
7213
|
|
|
6459
7214
|
// src/commands/workspace/config.ts
|
|
6460
|
-
import
|
|
7215
|
+
import path45 from "node:path";
|
|
6461
7216
|
import YAML from "yaml";
|
|
6462
7217
|
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
6463
7218
|
function parseWorkspaceConfig(text) {
|
|
@@ -6480,11 +7235,11 @@ function parseWorkspaceConfig(text) {
|
|
|
6480
7235
|
};
|
|
6481
7236
|
}
|
|
6482
7237
|
async function readWorkspaceConfig(workspaceRoot) {
|
|
6483
|
-
return parseWorkspaceConfig(await readText(
|
|
7238
|
+
return parseWorkspaceConfig(await readText(path45.join(workspaceRoot, WORKSPACE_FILE)));
|
|
6484
7239
|
}
|
|
6485
7240
|
|
|
6486
7241
|
// src/commands/workspace/discover.ts
|
|
6487
|
-
import
|
|
7242
|
+
import path46 from "node:path";
|
|
6488
7243
|
import fg4 from "fast-glob";
|
|
6489
7244
|
import YAML2 from "yaml";
|
|
6490
7245
|
var DEFAULT_MAX_DEPTH = 3;
|
|
@@ -6515,8 +7270,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
6515
7270
|
const gitDirs = /* @__PURE__ */ new Set();
|
|
6516
7271
|
const manifestDirs = /* @__PURE__ */ new Set();
|
|
6517
7272
|
for (const match of matches) {
|
|
6518
|
-
const base =
|
|
6519
|
-
const dir =
|
|
7273
|
+
const base = path46.posix.basename(match);
|
|
7274
|
+
const dir = path46.posix.dirname(match);
|
|
6520
7275
|
const owner = dir === "." ? "." : dir;
|
|
6521
7276
|
if (base === ".git") gitDirs.add(owner);
|
|
6522
7277
|
else manifestDirs.add(owner);
|
|
@@ -6532,9 +7287,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
6532
7287
|
}
|
|
6533
7288
|
repoRoots.sort((a, b) => a.localeCompare(b));
|
|
6534
7289
|
return mapWithConcurrency(repoRoots, async (relDir) => {
|
|
6535
|
-
const absDir =
|
|
6536
|
-
const pkg = await readJson(
|
|
6537
|
-
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name :
|
|
7290
|
+
const absDir = path46.resolve(workspaceRoot, relDir);
|
|
7291
|
+
const pkg = await readJson(path46.join(absDir, "package.json"));
|
|
7292
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path46.basename(relDir === "." ? workspaceRoot : absDir);
|
|
6538
7293
|
let role = "auto";
|
|
6539
7294
|
try {
|
|
6540
7295
|
const scan = await scanProject(absDir);
|
|
@@ -6577,7 +7332,7 @@ function renderWorkspaceYaml(config) {
|
|
|
6577
7332
|
});
|
|
6578
7333
|
}
|
|
6579
7334
|
async function runDiscover(workspaceRoot, opts = {}) {
|
|
6580
|
-
const yamlPath =
|
|
7335
|
+
const yamlPath = path46.join(workspaceRoot, "haus.workspace.yaml");
|
|
6581
7336
|
const existingText = await readText(yamlPath);
|
|
6582
7337
|
const existing = parseWorkspaceConfig(existingText);
|
|
6583
7338
|
if (existingText && !existing) {
|
|
@@ -6614,18 +7369,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
|
|
|
6614
7369
|
|
|
6615
7370
|
// src/commands/workspace/doctor.ts
|
|
6616
7371
|
import { existsSync as existsSync3 } from "node:fs";
|
|
6617
|
-
import
|
|
7372
|
+
import path48 from "node:path";
|
|
6618
7373
|
|
|
6619
7374
|
// src/commands/workspace/manifest.ts
|
|
6620
7375
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
6621
|
-
import
|
|
7376
|
+
import path47 from "node:path";
|
|
6622
7377
|
var MANIFEST_FILE = "workspace.manifest.json";
|
|
6623
7378
|
function manifestPath(workspaceRoot) {
|
|
6624
7379
|
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
6625
7380
|
}
|
|
6626
7381
|
function hausVersion() {
|
|
6627
7382
|
try {
|
|
6628
|
-
const pkg = JSON.parse(readFileSync3(
|
|
7383
|
+
const pkg = JSON.parse(readFileSync3(path47.join(packageRoot(), "package.json"), "utf8"));
|
|
6629
7384
|
return pkg.version ?? "0.0.0";
|
|
6630
7385
|
} catch {
|
|
6631
7386
|
return "0.0.0";
|
|
@@ -6690,8 +7445,9 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
6690
7445
|
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
6691
7446
|
}
|
|
6692
7447
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
7448
|
+
const catalogRefByRepo = [];
|
|
6693
7449
|
for (const repo of config.repos) {
|
|
6694
|
-
const repoRoot =
|
|
7450
|
+
const repoRoot = path48.resolve(workspaceRoot, repo.path);
|
|
6695
7451
|
const entry = manifestByName.get(repo.name);
|
|
6696
7452
|
if (!entry) {
|
|
6697
7453
|
flag({
|
|
@@ -6724,6 +7480,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
6724
7480
|
});
|
|
6725
7481
|
}
|
|
6726
7482
|
const lock = await checkLock(repoRoot);
|
|
7483
|
+
if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
|
|
6727
7484
|
if (!existsSync3(hausPath(repoRoot, "haus.lock.json"))) {
|
|
6728
7485
|
flag({
|
|
6729
7486
|
repo: repo.name,
|
|
@@ -6741,6 +7498,14 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
6741
7498
|
ok(`- ${repo.name}: OK (${lock.count} lock item(s))`);
|
|
6742
7499
|
}
|
|
6743
7500
|
}
|
|
7501
|
+
const distinctRefs = new Set(catalogRefByRepo.map((r) => r.ref));
|
|
7502
|
+
if (distinctRefs.size > 1) {
|
|
7503
|
+
flag({
|
|
7504
|
+
repo: "(workspace)",
|
|
7505
|
+
kind: "catalog-ref-mismatch",
|
|
7506
|
+
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.`
|
|
7507
|
+
});
|
|
7508
|
+
}
|
|
6744
7509
|
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
6745
7510
|
}
|
|
6746
7511
|
function emit(args) {
|
|
@@ -6765,11 +7530,11 @@ function emit(args) {
|
|
|
6765
7530
|
|
|
6766
7531
|
// src/commands/workspace/setup.ts
|
|
6767
7532
|
import { existsSync as existsSync4, statSync } from "node:fs";
|
|
6768
|
-
import
|
|
7533
|
+
import path50 from "node:path";
|
|
6769
7534
|
|
|
6770
7535
|
// src/claude/write-workspace-claude-md.ts
|
|
6771
|
-
import
|
|
6772
|
-
import
|
|
7536
|
+
import path49 from "node:path";
|
|
7537
|
+
import fs36 from "fs-extra";
|
|
6773
7538
|
function buildWorkspaceImportBlock(client, members) {
|
|
6774
7539
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
6775
7540
|
const body = [
|
|
@@ -6787,15 +7552,16 @@ ${BLOCK_END}`;
|
|
|
6787
7552
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
6788
7553
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
6789
7554
|
const dryRun = opts.dryRun ?? false;
|
|
6790
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
7555
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path49.join(workspaceRoot, "CLAUDE.md");
|
|
6791
7556
|
const say = opts.quiet ? () => {
|
|
6792
7557
|
} : log;
|
|
6793
|
-
const
|
|
7558
|
+
const existed = await fs36.pathExists(filePath);
|
|
7559
|
+
const prev = existed ? await fs36.readFile(filePath, "utf8") : "";
|
|
6794
7560
|
const next = opts.collision ? `${block}
|
|
6795
7561
|
` : injectHausBlock(prev, block);
|
|
6796
7562
|
const printable = displayPath(workspaceRoot, filePath);
|
|
6797
7563
|
if (dryRun) {
|
|
6798
|
-
if (!
|
|
7564
|
+
if (!existed) {
|
|
6799
7565
|
say(createUnifiedDiff(printable, "", next));
|
|
6800
7566
|
} else if (hasTextChanged(prev, next)) {
|
|
6801
7567
|
say(createUnifiedDiff(printable, prev, next));
|
|
@@ -6804,31 +7570,33 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
6804
7570
|
}
|
|
6805
7571
|
return filePath;
|
|
6806
7572
|
}
|
|
6807
|
-
if (hasTextChanged(prev, next)
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
7573
|
+
if (!existed || hasTextChanged(prev, next)) {
|
|
7574
|
+
if (existed) {
|
|
7575
|
+
const diffText = createUnifiedDiff(printable, prev, next);
|
|
7576
|
+
const summary = summarizeDiff(diffText);
|
|
7577
|
+
say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
7578
|
+
}
|
|
7579
|
+
await writeText(filePath, next);
|
|
6811
7580
|
}
|
|
6812
|
-
await writeText(filePath, next);
|
|
6813
7581
|
return filePath;
|
|
6814
7582
|
}
|
|
6815
7583
|
|
|
6816
7584
|
// src/commands/workspace/setup.ts
|
|
6817
7585
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
6818
|
-
let dir =
|
|
7586
|
+
let dir = path50.resolve(start);
|
|
6819
7587
|
for (; ; ) {
|
|
6820
|
-
if (existsSync4(
|
|
6821
|
-
const parent =
|
|
6822
|
-
if (parent === dir) return
|
|
7588
|
+
if (existsSync4(path50.join(dir, WORKSPACE_FILE))) return dir;
|
|
7589
|
+
const parent = path50.dirname(dir);
|
|
7590
|
+
if (parent === dir) return path50.resolve(start);
|
|
6823
7591
|
dir = parent;
|
|
6824
7592
|
}
|
|
6825
7593
|
}
|
|
6826
7594
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
6827
|
-
return
|
|
7595
|
+
return path50.resolve(workspaceRoot, repoPath) === path50.resolve(workspaceRoot);
|
|
6828
7596
|
}
|
|
6829
7597
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
6830
7598
|
const apply = options.write ?? false;
|
|
6831
|
-
const configText = await readText(
|
|
7599
|
+
const configText = await readText(path50.join(workspaceRoot, WORKSPACE_FILE));
|
|
6832
7600
|
if (!configText) {
|
|
6833
7601
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
6834
7602
|
process.exitCode = 1;
|
|
@@ -6855,7 +7623,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
6855
7623
|
const statuses = [];
|
|
6856
7624
|
const aggregateInputs = [];
|
|
6857
7625
|
for (const repo of repos) {
|
|
6858
|
-
const repoRoot =
|
|
7626
|
+
const repoRoot = path50.resolve(workspaceRoot, repo.path);
|
|
6859
7627
|
say(`
|
|
6860
7628
|
\u2192 ${repo.name} (${repo.path})`);
|
|
6861
7629
|
try {
|
|
@@ -6926,7 +7694,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
6926
7694
|
const status = statusByName.get(repo.name);
|
|
6927
7695
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
6928
7696
|
if (status?.status === "ok") {
|
|
6929
|
-
const lock = await checkLock(
|
|
7697
|
+
const lock = await checkLock(path50.resolve(workspaceRoot, repo.path));
|
|
6930
7698
|
manifestRepos.push({
|
|
6931
7699
|
name: repo.name,
|
|
6932
7700
|
path: repo.path,
|
|
@@ -6988,6 +7756,91 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
|
|
|
6988
7756
|
return result;
|
|
6989
7757
|
}
|
|
6990
7758
|
|
|
7759
|
+
// src/commands/workspace/undo.ts
|
|
7760
|
+
import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
|
|
7761
|
+
import path51 from "node:path";
|
|
7762
|
+
import fs37 from "fs-extra";
|
|
7763
|
+
var WORKSPACE_AGGREGATE_FILES = [
|
|
7764
|
+
"workspace-summary.json",
|
|
7765
|
+
"dependency-ownership-map.json",
|
|
7766
|
+
"cross-repo-summary.md",
|
|
7767
|
+
"workspace-context-map.json"
|
|
7768
|
+
];
|
|
7769
|
+
async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
7770
|
+
const config = await readWorkspaceConfig(workspaceRoot);
|
|
7771
|
+
if (!config) {
|
|
7772
|
+
error(`Missing or malformed ${WORKSPACE_FILE}. Nothing to undo.`);
|
|
7773
|
+
process.exitCode = 1;
|
|
7774
|
+
return;
|
|
7775
|
+
}
|
|
7776
|
+
if (config.repos.length === 0) {
|
|
7777
|
+
log(`No repos configured in ${WORKSPACE_FILE}.`);
|
|
7778
|
+
}
|
|
7779
|
+
if (!options.yes) {
|
|
7780
|
+
const ok = await confirm(
|
|
7781
|
+
`Undo haus setup for ${config.repos.length} repo(s) in this workspace, plus workspace-root artifacts?`
|
|
7782
|
+
);
|
|
7783
|
+
if (!ok) {
|
|
7784
|
+
log("Cancelled.");
|
|
7785
|
+
return;
|
|
7786
|
+
}
|
|
7787
|
+
}
|
|
7788
|
+
let anyRepoFailed = false;
|
|
7789
|
+
for (const repo of config.repos) {
|
|
7790
|
+
const repoRoot = path51.resolve(workspaceRoot, repo.path);
|
|
7791
|
+
log(`
|
|
7792
|
+
\u2192 ${repo.name} (${repo.path})`);
|
|
7793
|
+
try {
|
|
7794
|
+
if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
7795
|
+
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
7796
|
+
}
|
|
7797
|
+
await runUndo({ yes: true, root: repoRoot });
|
|
7798
|
+
} catch (err) {
|
|
7799
|
+
anyRepoFailed = true;
|
|
7800
|
+
warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
7801
|
+
}
|
|
7802
|
+
}
|
|
7803
|
+
for (const rel of WORKSPACE_AGGREGATE_FILES) {
|
|
7804
|
+
const abs = hausPath(workspaceRoot, rel);
|
|
7805
|
+
if (await fs37.pathExists(abs)) {
|
|
7806
|
+
await fs37.remove(abs);
|
|
7807
|
+
log(`Removed ${path51.relative(workspaceRoot, abs)}`);
|
|
7808
|
+
}
|
|
7809
|
+
}
|
|
7810
|
+
const manifestFile = manifestPath(workspaceRoot);
|
|
7811
|
+
if (await fs37.pathExists(manifestFile)) {
|
|
7812
|
+
await fs37.remove(manifestFile);
|
|
7813
|
+
log(`Removed ${path51.relative(workspaceRoot, manifestFile)}`);
|
|
7814
|
+
}
|
|
7815
|
+
const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
|
|
7816
|
+
if (await fs37.pathExists(workspaceMdPath)) {
|
|
7817
|
+
await fs37.remove(workspaceMdPath);
|
|
7818
|
+
log(`Removed ${path51.relative(workspaceRoot, workspaceMdPath)}`);
|
|
7819
|
+
} else {
|
|
7820
|
+
const claudeMdPath = path51.join(workspaceRoot, "CLAUDE.md");
|
|
7821
|
+
if (existsSync5(claudeMdPath)) {
|
|
7822
|
+
const prev = await readText(claudeMdPath);
|
|
7823
|
+
if (prev?.includes(BLOCK_BEGIN)) {
|
|
7824
|
+
const next = stripHausBlock(prev);
|
|
7825
|
+
if (next.length === 0) {
|
|
7826
|
+
await fs37.remove(claudeMdPath);
|
|
7827
|
+
log("Removed CLAUDE.md (only contained the workspace haus import block).");
|
|
7828
|
+
} else {
|
|
7829
|
+
await fs37.writeFile(claudeMdPath, next, "utf8");
|
|
7830
|
+
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
7831
|
+
}
|
|
7832
|
+
}
|
|
7833
|
+
}
|
|
7834
|
+
}
|
|
7835
|
+
log(`${WORKSPACE_FILE} left in place \u2014 it's your own config, not haus-owned output.`);
|
|
7836
|
+
if (anyRepoFailed) {
|
|
7837
|
+
process.exitCode = 1;
|
|
7838
|
+
warn("Workspace undo finished with at least one repo failure \u2014 see above.");
|
|
7839
|
+
} else {
|
|
7840
|
+
log("Workspace undo complete.");
|
|
7841
|
+
}
|
|
7842
|
+
}
|
|
7843
|
+
|
|
6991
7844
|
// src/commands/workspace.ts
|
|
6992
7845
|
function normalizeOnly(only) {
|
|
6993
7846
|
if (!only) return void 0;
|
|
@@ -7016,7 +7869,7 @@ relationships: []
|
|
|
7016
7869
|
log("Workspace initialized.");
|
|
7017
7870
|
}
|
|
7018
7871
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
7019
|
-
const configText = await readText(
|
|
7872
|
+
const configText = await readText(path52.join(workspaceRoot, WORKSPACE_FILE));
|
|
7020
7873
|
if (!configText) {
|
|
7021
7874
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
7022
7875
|
process.exitCode = 1;
|
|
@@ -7038,9 +7891,9 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
7038
7891
|
const inputs = [];
|
|
7039
7892
|
const failures = [];
|
|
7040
7893
|
for (const repo of config.repos) {
|
|
7041
|
-
const repoRoot =
|
|
7894
|
+
const repoRoot = path52.resolve(workspaceRoot, repo.path);
|
|
7042
7895
|
try {
|
|
7043
|
-
if (!
|
|
7896
|
+
if (!existsSync6(repoRoot) || !statSync3(repoRoot).isDirectory()) {
|
|
7044
7897
|
throw new Error("path is not a directory");
|
|
7045
7898
|
}
|
|
7046
7899
|
const result = await scanProject(repoRoot);
|
|
@@ -7092,13 +7945,16 @@ async function runWorkspace(action, options = {}) {
|
|
|
7092
7945
|
case "doctor":
|
|
7093
7946
|
await runWorkspaceDoctor(workspaceRoot, { json: options.json });
|
|
7094
7947
|
return;
|
|
7948
|
+
case "undo":
|
|
7949
|
+
await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
|
|
7950
|
+
return;
|
|
7095
7951
|
}
|
|
7096
7952
|
}
|
|
7097
7953
|
|
|
7098
7954
|
// src/cli.ts
|
|
7099
7955
|
function cliVersion() {
|
|
7100
7956
|
try {
|
|
7101
|
-
const pkgPath =
|
|
7957
|
+
const pkgPath = path53.join(packageRoot(), "package.json");
|
|
7102
7958
|
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
7103
7959
|
return pkg.version ?? "0.0.0";
|
|
7104
7960
|
} catch {
|
|
@@ -7108,7 +7964,7 @@ function cliVersion() {
|
|
|
7108
7964
|
var program = new Command();
|
|
7109
7965
|
function validateRuntimeNodeVersion() {
|
|
7110
7966
|
try {
|
|
7111
|
-
const pkgPath =
|
|
7967
|
+
const pkgPath = path53.join(packageRoot(), "package.json");
|
|
7112
7968
|
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
7113
7969
|
const requiredRange = pkg.engines?.node;
|
|
7114
7970
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
@@ -7139,8 +7995,17 @@ program.command("apply").option("--dry-run").option("--write").option("--select"
|
|
|
7139
7995
|
).option(
|
|
7140
7996
|
"--refill-config",
|
|
7141
7997
|
"Fill still-blank fields in an existing workflow-config.md without touching edited ones"
|
|
7142
|
-
).option("--force", "Overwrite user-modified managed workflow files").
|
|
7998
|
+
).option("--force", "Overwrite user-modified managed workflow files").option(
|
|
7999
|
+
"--prune",
|
|
8000
|
+
"Remove lock-tracked catalog items no longer in the current recommendation (hash-gated, backed up first)"
|
|
8001
|
+
).action(runApply);
|
|
7143
8002
|
program.command("undo").option("-y, --yes", "Skip confirmation").action(runUndo);
|
|
8003
|
+
var backups = program.command("backups");
|
|
8004
|
+
backups.command("list").description("List backups under .haus-workflow/backups/").action(() => runBackups("list"));
|
|
8005
|
+
backups.command("restore <id>").description("Restore a specific backup by id (see `haus backups list`)").option("-y, --yes", "Skip confirmation").action((id, opts) => runBackups("restore", { id, yes: opts.yes }));
|
|
8006
|
+
backups.command("prune").description("Delete old backups by age and/or count (requires --older-than or --keep)").option("--older-than <days>", "Remove backups older than N days").option("--keep <n>", "Keep only the N most recent backups").option("-y, --yes", "Skip confirmation").action(
|
|
8007
|
+
(opts) => runBackups("prune", { olderThan: opts.olderThan, keep: opts.keep, yes: opts.yes })
|
|
8008
|
+
);
|
|
7144
8009
|
program.command("explain-recommendation").option("--json").action(runExplainRecommendation);
|
|
7145
8010
|
program.command("init").option("--json").action(runInit);
|
|
7146
8011
|
program.command("clone").description("Clone a single git repository by URL").argument("<url>", "Git URL to clone").argument("[dir]", "Target directory (default: repo name derived from the URL)").option("--dry-run", "Print what would happen without cloning").action(
|
|
@@ -7149,7 +8014,7 @@ program.command("clone").description("Clone a single git repository by URL").arg
|
|
|
7149
8014
|
program.command("refresh").action(runRefresh);
|
|
7150
8015
|
program.command("catalog-audit").action(runCatalogAudit);
|
|
7151
8016
|
program.command("validate-catalog").argument("[manifest]").action(runValidateCatalog);
|
|
7152
|
-
program.command("update").option("--check").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
|
|
8017
|
+
program.command("update").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
|
|
7153
8018
|
program.command("install").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").option("--postinstall", "Run by the npm postinstall hook; prints a plain-language change notice").action(runInstall);
|
|
7154
8019
|
program.command("scaffold [ids...]").description(
|
|
7155
8020
|
"Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
|
|
@@ -7176,6 +8041,7 @@ workspace.command("discover").description("Auto-find member repos and write/merg
|
|
|
7176
8041
|
workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
|
|
7177
8042
|
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));
|
|
7178
8043
|
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));
|
|
8044
|
+
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));
|
|
7179
8045
|
program.parseAsync(process.argv).catch((err) => {
|
|
7180
8046
|
const message = err instanceof Error ? err.message : String(err);
|
|
7181
8047
|
error(message);
|