@lovrozagar/grunt 0.6.2 → 0.7.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/cli/init.mjs CHANGED
@@ -16,6 +16,15 @@ import {
16
16
  remergeGuardedRoots,
17
17
  withGuardedCheckInteriors,
18
18
  } from "../scripts/guarded-md.mjs"
19
+ import {
20
+ PACKAGE_MANAGERS,
21
+ UNKNOWN_PACKAGE_MANAGER,
22
+ detectPackageManager,
23
+ installArgs,
24
+ isPackageManager,
25
+ runScriptArgs,
26
+ } from "../scripts/package-manager.mjs"
27
+ import { LAW_REL, emitMaps } from "../scripts/emit-maps.mjs"
19
28
 
20
29
  export {
21
30
  GUARDED_ROOT_FILES,
@@ -49,6 +58,7 @@ export const PRODUCT_SCRIPTS = [
49
58
  "pipeline.mjs",
50
59
  "grunt-job.mjs",
51
60
  "parse-need.mjs",
61
+ "package-manager.mjs",
52
62
  "persist-handoff.mjs",
53
63
  "persist-implementation.mjs",
54
64
  "persist-tmp.mjs",
@@ -70,6 +80,9 @@ export const PRODUCT_SCRIPTS = [
70
80
  ]
71
81
 
72
82
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"]
83
+ /** Generated on dest from the dest tree (grunt refs + consumer extras). Never stamp the package copies. */
84
+ export const GENERATED_MAP_FILES = new Set(["INDEX.md", "skills-map.md", "refs-map.md"])
85
+ const COPY_ROOT_IF_MISSING = [".mcp.json"]
73
86
  export const RETIRED_SKILLS = ["parent", "solo", "cascade"]
74
87
  export const RETIRED_AGENTS = ["implementer", "thinker"]
75
88
  /** Cumulative. Dest `scripts/<name>` deleted on init/upgrade even if no longer shipped. */
@@ -116,9 +129,9 @@ const AGENT_MIRROR_DIRS = [
116
129
  [".codex", "agents"],
117
130
  ]
118
131
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
119
- // .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
120
- // (run via consumer `grunt:rulesync:generate`, see mergeClaudeSettings ordering) owns and
121
- // merges it, so a plain-copy would just be clobbered/redundant.
132
+ // .mcp.json: copy when dest is missing so first init has a root file. emit-mcp-policy
133
+ // (generate) still owns the merge from .rulesync/mcp-policy.jsonc.
134
+ export const GRUNT_PACKAGE = "@lovrozagar/grunt"
122
135
  export const GRUNT_NPM_PREFIX = "grunt:"
123
136
  export function toGruntScriptName(k) {
124
137
  return k.startsWith(GRUNT_NPM_PREFIX) ? k : `${GRUNT_NPM_PREFIX}${k}`
@@ -279,7 +292,7 @@ export function mergeClaudeSettings(destRoot, pkgRoot) {
279
292
  }
280
293
 
281
294
  function looksGruntOwnedPrefix(prefix) {
282
- return /rulesync|sync:globals|^npm run |guarded-roots\.mjs|hooks-union\.mjs|doctor\.mjs|sync-global-settings\.mjs|check-globals\.mjs|purge-global-mcps\.mjs/.test(
295
+ return /rulesync|sync:globals|^(?:npm|yarn|pnpm|bun) run |guarded-roots\.mjs|hooks-union\.mjs|doctor\.mjs|sync-global-settings\.mjs|check-globals\.mjs|purge-global-mcps\.mjs/.test(
283
296
  prefix,
284
297
  )
285
298
  }
@@ -317,24 +330,47 @@ function mergeScriptValue(newSrc, cur) {
317
330
  return newSrc
318
331
  }
319
332
 
320
- function rewriteNpmRunRefs(scripts, srcKeys) {
333
+ function rewritePackageManagerRunRefs(scripts, srcKeys) {
321
334
  const keys = srcKeys
322
335
  .filter((k) => k !== toGruntScriptName(k))
323
336
  .sort((a, b) => b.length - a.length)
324
337
  if (!keys.length) return
338
+ const pm = PACKAGE_MANAGERS.join("|")
325
339
  for (const name of Object.keys(scripts)) {
326
340
  let val = scripts[name]
327
341
  if (typeof val !== "string") continue
328
342
  for (const k of keys) {
329
343
  val = val.replace(
330
- new RegExp(`npm run ${escapeRe(k)}(?=$|\\s|;|&)`, "g"),
331
- `npm run ${toGruntScriptName(k)}`,
344
+ new RegExp(`(${pm}) run ${escapeRe(k)}(?=$|\\s|;|&)`, "g"),
345
+ `$1 run ${toGruntScriptName(k)}`,
332
346
  )
333
347
  }
334
348
  scripts[name] = val
335
349
  }
336
350
  }
337
351
 
352
+ export function placeGruntDevDependency(destPkg, srcPkg) {
353
+ const deps =
354
+ destPkg.dependencies && typeof destPkg.dependencies === "object"
355
+ ? destPkg.dependencies
356
+ : null
357
+ destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
358
+ const fromDep = deps?.[GRUNT_PACKAGE]
359
+ const fromDev = destPkg.devDependencies[GRUNT_PACKAGE]
360
+ if (fromDep != null && deps) {
361
+ delete destPkg.dependencies[GRUNT_PACKAGE]
362
+ if (fromDev == null) destPkg.devDependencies[GRUNT_PACKAGE] = fromDep
363
+ if (Object.keys(destPkg.dependencies).length === 0) delete destPkg.dependencies
364
+ }
365
+ if (
366
+ destPkg.devDependencies[GRUNT_PACKAGE] == null &&
367
+ srcPkg?.name === GRUNT_PACKAGE &&
368
+ srcPkg.version
369
+ ) {
370
+ destPkg.devDependencies[GRUNT_PACKAGE] = srcPkg.version
371
+ }
372
+ }
373
+
338
374
  export function mergePackageJson(dest, pkgRoot) {
339
375
  const destPath = path.join(dest, "package.json")
340
376
  let destPkg = {}
@@ -377,18 +413,47 @@ export function mergePackageJson(dest, pkgRoot) {
377
413
  const staleKey = `grunt:yolo:${k}`
378
414
  if (destPkg.scripts[staleKey] === v) delete destPkg.scripts[staleKey]
379
415
  }
380
- rewriteNpmRunRefs(destPkg.scripts, srcKeys)
416
+ rewritePackageManagerRunRefs(destPkg.scripts, srcKeys)
381
417
  destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
382
418
  destPkg.devDependencies["smol-toml"] = srcPkg.devDependencies["smol-toml"]
383
419
  destPkg.devDependencies["rulesync"] = srcPkg.devDependencies["rulesync"]
384
420
  const clack = srcPkg.dependencies?.["@clack/prompts"]
385
421
  if (clack) destPkg.devDependencies["@clack/prompts"] = clack
422
+ placeGruntDevDependency(destPkg, srcPkg)
386
423
  destPkg.scripts = sortKeys(destPkg.scripts)
387
424
  destPkg.devDependencies = sortKeys(destPkg.devDependencies)
388
425
  fs.writeFileSync(destPath, `${JSON.stringify(destPkg, null, 2)}\n`)
389
426
  }
390
427
 
391
- export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase } = {}) {
428
+ function copyRootIfMissing(dest, pkgRoot) {
429
+ for (const file of COPY_ROOT_IF_MISSING) {
430
+ const src = path.join(pkgRoot, file)
431
+ const d = path.join(dest, file)
432
+ if (!fs.existsSync(src) || fs.existsSync(d)) continue
433
+ fs.copyFileSync(src, d)
434
+ }
435
+ }
436
+
437
+ function emitDestMaps(dest) {
438
+ if (!fs.existsSync(path.join(dest, LAW_REL))) return
439
+ const maps = emitMaps({ workspaceRoot: dest })
440
+ if (!maps.ok) throw new Error(maps.error || "emit-maps failed")
441
+ }
442
+
443
+ export function resolveInitPackageManager(dest, { packageManager, env = process.env } = {}) {
444
+ if (packageManager) {
445
+ const name = String(packageManager).toLowerCase()
446
+ if (!isPackageManager(name)) {
447
+ throw new Error(UNKNOWN_PACKAGE_MANAGER)
448
+ }
449
+ return name
450
+ }
451
+ const detected = detectPackageManager({ cwd: dest, env })
452
+ if (!detected.manager) throw new Error(UNKNOWN_PACKAGE_MANAGER)
453
+ return detected.manager
454
+ }
455
+
456
+ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase, packageManager, env = process.env } = {}) {
392
457
  dest = path.resolve(dest)
393
458
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
394
459
  const skipGlobalsApply =
@@ -429,6 +494,12 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
429
494
  filter: (s) => path.basename(s) !== "settings.json",
430
495
  })
431
496
  mergeClaudeSettings(dest, pkgRoot)
497
+ } else if (dir === ".rulesync") {
498
+ fs.cpSync(src, d, {
499
+ recursive: true,
500
+ force: true,
501
+ filter: (s) => !GENERATED_MAP_FILES.has(path.basename(s)),
502
+ })
432
503
  } else {
433
504
  fs.cpSync(src, d, { recursive: true, force: true })
434
505
  }
@@ -453,26 +524,31 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
453
524
  fs.mkdirSync(path.join(dest, ".tmp"), { recursive: true })
454
525
  mergeGitignore(dest)
455
526
 
456
- if (!self) mergePackageJson(dest, pkgRoot)
527
+ if (!self) {
528
+ mergePackageJson(dest, pkgRoot)
529
+ copyRootIfMissing(dest, pkgRoot)
530
+ emitDestMaps(dest)
531
+ }
457
532
  })
458
533
 
459
534
  // Self-skip: file/dir merge + .tmp + gitignore only. No package.json merge,
460
- // npm install, or generate/sync/check (would mutate this package in-place).
535
+ // install, or generate/sync/check (would mutate this package in-place).
461
536
  if (self) return
462
537
 
463
- const runNpm = (name, args) => {
538
+ const pm = resolveInitPackageManager(dest, { packageManager, env })
539
+ const runPm = (name, args) => {
464
540
  onPhase?.(name, "start")
465
541
  onPhase?.(name, "stop")
466
- exec("npm", args, { cwd: dest, stdio: "inherit" })
542
+ exec(pm, args, { cwd: dest, stdio: "inherit" })
467
543
  }
468
- runNpm("install", ["install"])
544
+ runPm("install", installArgs(pm))
469
545
  const guardedSnap = snapshotGuardedRoots(dest)
470
- runNpm("generate", ["run", toGruntScriptName("rulesync:generate")])
546
+ runPm("generate", runScriptArgs(pm, toGruntScriptName("rulesync:generate")))
471
547
  remergeGuardedRoots(dest, guardedSnap)
472
548
  if (!skipGlobalsApply) {
473
- runNpm("sync-globals", ["run", toGruntScriptName("sync:globals:apply")])
549
+ runPm("sync-globals", runScriptArgs(pm, toGruntScriptName("sync:globals:apply")))
474
550
  }
475
551
  withGuardedCheckInteriors(dest, () => {
476
- runNpm("check", ["run", toGruntScriptName("rulesync:check")])
552
+ runPm("check", runScriptArgs(pm, toGruntScriptName("rulesync:check")))
477
553
  })
478
554
  }
package/cli/init.test.ts CHANGED
@@ -4,6 +4,18 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ const emitMaps = vi.hoisted(() => vi.fn());
9
+ const actualEmitMaps = vi.hoisted(() => ({ fn: null as null | ((...args: unknown[]) => unknown) }));
10
+
11
+ vi.mock("../scripts/emit-maps.mjs", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("../scripts/emit-maps.mjs")>();
13
+ actualEmitMaps.fn = actual.emitMaps;
14
+ emitMaps.mockImplementation((...args: unknown[]) =>
15
+ actual.emitMaps(...(args as Parameters<typeof actual.emitMaps>)),
16
+ );
17
+ return { ...actual, emitMaps };
18
+ });
7
19
  import {
8
20
  MAX_GUARDED_MARKDOWN_BYTES,
9
21
  PRODUCT_SCRIPTS,
@@ -28,7 +40,10 @@ import {
28
40
  mergeGitignore,
29
41
  mergeGuardedContent,
30
42
  mergeGuardedMarkdown,
43
+ GENERATED_MAP_FILES,
44
+ GRUNT_PACKAGE,
31
45
  mergePackageJson,
46
+ placeGruntDevDependency,
32
47
  remergeGuardedRoots,
33
48
  samePath,
34
49
  shouldAutoSkipGlobals,
@@ -37,6 +52,7 @@ import {
37
52
  withGuardedCheckInteriors,
38
53
  writeMergedGuardedFile,
39
54
  } from "./init.mjs";
55
+ import { UNKNOWN_PACKAGE_MANAGER } from "../scripts/package-manager.mjs";
40
56
  import { runGuardedRoots } from "../scripts/guarded-roots.mjs";
41
57
 
42
58
  const tmpDirs: string[] = [];
@@ -44,6 +60,10 @@ afterEach(() => {
44
60
  for (const d of tmpDirs.splice(0)) {
45
61
  fs.rmSync(d, { recursive: true, force: true });
46
62
  }
63
+ emitMaps.mockReset();
64
+ emitMaps.mockImplementation((...args: unknown[]) =>
65
+ (actualEmitMaps.fn as (...a: unknown[]) => unknown)(...args),
66
+ );
47
67
  });
48
68
 
49
69
  function tmp(prefix: string) {
@@ -1408,6 +1428,113 @@ describe("mergePackageJson", () => {
1408
1428
  expect(out.scripts.check).toBe("npm run grunt:rulesync:check && npm run rulesync:check:raw");
1409
1429
  });
1410
1430
 
1431
+ it("rewrites yarn/pnpm/bun run refs the same way as npm run", () => {
1432
+ const pkgRoot = stubPkgRoot({
1433
+ name: "fixture-pkg",
1434
+ scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1435
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1436
+ });
1437
+ const dest = tmp("pj-pm-rewrite-");
1438
+ fs.writeFileSync(
1439
+ path.join(dest, "package.json"),
1440
+ JSON.stringify({
1441
+ scripts: {
1442
+ "rulesync:check": "node ./scripts/guarded-roots.mjs check",
1443
+ check: "pnpm run rulesync:check && yarn run rulesync:check && bun run rulesync:check",
1444
+ },
1445
+ }),
1446
+ );
1447
+ mergePackageJson(dest, pkgRoot);
1448
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1449
+ expect(out.scripts.check).toBe(
1450
+ "pnpm run grunt:rulesync:check && yarn run grunt:rulesync:check && bun run grunt:rulesync:check",
1451
+ );
1452
+ });
1453
+
1454
+ it("moves @lovrozagar/grunt from dependencies to devDependencies", () => {
1455
+ const pkgRoot = stubPkgRoot({
1456
+ name: GRUNT_PACKAGE,
1457
+ version: "0.6.2",
1458
+ scripts: { "rulesync:generate": "gen" },
1459
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1460
+ });
1461
+ const dest = tmp("pj-grunt-dep-");
1462
+ fs.writeFileSync(
1463
+ path.join(dest, "package.json"),
1464
+ JSON.stringify({
1465
+ name: "app",
1466
+ dependencies: { [GRUNT_PACKAGE]: "0.6.2", lodash: "4" },
1467
+ scripts: {},
1468
+ }),
1469
+ );
1470
+ mergePackageJson(dest, pkgRoot);
1471
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1472
+ expect(out.dependencies).toEqual({ lodash: "4" });
1473
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("0.6.2");
1474
+ });
1475
+
1476
+ it("drops empty dependencies after moving grunt", () => {
1477
+ const pkgRoot = stubPkgRoot({
1478
+ name: GRUNT_PACKAGE,
1479
+ version: "0.7.0",
1480
+ scripts: {},
1481
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1482
+ });
1483
+ const dest = tmp("pj-grunt-onlydep-");
1484
+ fs.writeFileSync(
1485
+ path.join(dest, "package.json"),
1486
+ JSON.stringify({ dependencies: { [GRUNT_PACKAGE]: "0.6.2" } }),
1487
+ );
1488
+ mergePackageJson(dest, pkgRoot);
1489
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1490
+ expect(out.dependencies).toBeUndefined();
1491
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("0.6.2");
1492
+ });
1493
+
1494
+ it("adds missing grunt as a devDependency from the running package version", () => {
1495
+ const pkgRoot = stubPkgRoot({
1496
+ name: GRUNT_PACKAGE,
1497
+ version: "0.7.0",
1498
+ scripts: {},
1499
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1500
+ });
1501
+ const dest = tmp("pj-grunt-add-");
1502
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app" }));
1503
+ mergePackageJson(dest, pkgRoot);
1504
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1505
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("0.7.0");
1506
+ });
1507
+
1508
+ it("keeps an existing grunt devDependency version", () => {
1509
+ const pkgRoot = stubPkgRoot({
1510
+ name: GRUNT_PACKAGE,
1511
+ version: "0.7.0",
1512
+ scripts: {},
1513
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1514
+ });
1515
+ const dest = tmp("pj-grunt-keep-");
1516
+ fs.writeFileSync(
1517
+ path.join(dest, "package.json"),
1518
+ JSON.stringify({
1519
+ devDependencies: { [GRUNT_PACKAGE]: "^0.6.2" },
1520
+ dependencies: { [GRUNT_PACKAGE]: "0.6.2" },
1521
+ }),
1522
+ );
1523
+ mergePackageJson(dest, pkgRoot);
1524
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1525
+ expect(out.dependencies).toBeUndefined();
1526
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("^0.6.2");
1527
+ });
1528
+
1529
+ it("placeGruntDevDependency is a no-op when src is not grunt", () => {
1530
+ const destPkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> } = {
1531
+ dependencies: { lodash: "4" },
1532
+ };
1533
+ placeGruntDevDependency(destPkg, { name: "fixture-pkg", version: "1.0.0" });
1534
+ expect(destPkg.dependencies).toEqual({ lodash: "4" });
1535
+ expect(destPkg.devDependencies?.[GRUNT_PACKAGE]).toBeUndefined();
1536
+ });
1537
+
1411
1538
  it("skips non-string dest scripts when rewriting npm run refs", () => {
1412
1539
  const pkgRoot = stubPkgRoot({
1413
1540
  name: "fixture-pkg",
@@ -1526,7 +1653,7 @@ describe("init", () => {
1526
1653
  const pkgRoot = stubPkgRoot();
1527
1654
  const dest = tmp("grunt-dest-");
1528
1655
  const exec = vi.fn();
1529
- init(dest, { pkgRoot, execFileSync: exec });
1656
+ init(dest, { pkgRoot, execFileSync: exec, packageManager: "npm" });
1530
1657
 
1531
1658
  for (const d of COPY_DIRS) {
1532
1659
  if (d === ".claude") continue;
@@ -1741,7 +1868,7 @@ describe("init", () => {
1741
1868
  const pkgRoot = stubPkgRoot();
1742
1869
  const dest = tmp("grunt-skip-globals-");
1743
1870
  const exec = vi.fn();
1744
- init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true });
1871
+ init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true, packageManager: "npm" });
1745
1872
  expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
1746
1873
  ["npm", ["install"]],
1747
1874
  ["npm", ["run", "grunt:rulesync:generate"]],
@@ -1879,6 +2006,128 @@ describe("init", () => {
1879
2006
  expect(claude.split("<!-- grunt:end -->")).toHaveLength(2);
1880
2007
  });
1881
2008
 
2009
+ it("pnpm-lock.yaml dest installs with pnpm even if invocation is npm", () => {
2010
+ const pkgRoot = stubPkgRoot();
2011
+ const dest = tmp("grunt-pnpm-");
2012
+ fs.writeFileSync(path.join(dest, "pnpm-lock.yaml"), "lockfileVersion: 9\n");
2013
+ const exec = vi.fn();
2014
+ init(dest, {
2015
+ pkgRoot,
2016
+ execFileSync: exec,
2017
+ env: { npm_config_user_agent: "npm/10.0.0 node/v22" },
2018
+ });
2019
+ expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
2020
+ ["pnpm", ["install"]],
2021
+ ["pnpm", ["run", "grunt:rulesync:generate"]],
2022
+ ["pnpm", ["run", "grunt:sync:globals:apply"]],
2023
+ ["pnpm", ["run", "grunt:rulesync:check"]],
2024
+ ]);
2025
+ });
2026
+
2027
+ it("packageManager option wins over dest lockfile", () => {
2028
+ const pkgRoot = stubPkgRoot();
2029
+ const dest = tmp("grunt-pm-opt-");
2030
+ fs.writeFileSync(path.join(dest, "yarn.lock"), "");
2031
+ const exec = vi.fn();
2032
+ init(dest, { pkgRoot, execFileSync: exec, packageManager: "bun" });
2033
+ expect(exec.mock.calls[0][0]).toBe("bun");
2034
+ });
2035
+
2036
+ it("unknown manager throws and does not exec", () => {
2037
+ const pkgRoot = stubPkgRoot();
2038
+ const dest = tmp("grunt-pm-unknown-");
2039
+ const exec = vi.fn();
2040
+ expect(() =>
2041
+ init(dest, { pkgRoot, execFileSync: exec, env: {}, packageManager: undefined }),
2042
+ ).toThrow(UNKNOWN_PACKAGE_MANAGER);
2043
+ expect(exec).not.toHaveBeenCalled();
2044
+ });
2045
+
2046
+ it("invalid packageManager option throws", () => {
2047
+ const pkgRoot = stubPkgRoot();
2048
+ const dest = tmp("grunt-pm-bad-");
2049
+ const exec = vi.fn();
2050
+ expect(() => init(dest, { pkgRoot, execFileSync: exec, packageManager: "deno" })).toThrow(
2051
+ UNKNOWN_PACKAGE_MANAGER,
2052
+ );
2053
+ expect(exec).not.toHaveBeenCalled();
2054
+ });
2055
+
2056
+ it("yarn.lock dest uses yarn run args without npm --", () => {
2057
+ const pkgRoot = stubPkgRoot();
2058
+ const dest = tmp("grunt-yarn-");
2059
+ fs.writeFileSync(path.join(dest, "yarn.lock"), "");
2060
+ const exec = vi.fn();
2061
+ init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true, env: {} });
2062
+ expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
2063
+ ["yarn", ["install"]],
2064
+ ["yarn", ["run", "grunt:rulesync:generate"]],
2065
+ ["yarn", ["run", "grunt:rulesync:check"]],
2066
+ ]);
2067
+ });
2068
+
2069
+ it("merges dest refs into INDEX and writes .mcp.json when missing", () => {
2070
+ const dest = tmp("grunt-maps-merge-");
2071
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2072
+ fs.writeFileSync(
2073
+ path.join(dest, ".rulesync", "reference", "stripe.md"),
2074
+ "# Stripe\n\nConsumer stripe payments ref.\n",
2075
+ );
2076
+ const exec = vi.fn();
2077
+ init(dest, { execFileSync: exec, skipGlobals: true, packageManager: "npm" });
2078
+ expect(GENERATED_MAP_FILES.has("INDEX.md")).toBe(true);
2079
+ const index = fs.readFileSync(path.join(dest, ".rulesync", "reference", "INDEX.md"), "utf8");
2080
+ expect(index).toMatch(/stripe\.md/);
2081
+ expect(index).toMatch(/Consumer stripe payments ref/);
2082
+ expect(index).toMatch(/browser\.md/);
2083
+ const refsMap = fs.readFileSync(path.join(dest, ".rulesync", "reference", "refs-map.md"), "utf8");
2084
+ expect(refsMap).toMatch(/stripe\.md/);
2085
+ expect(fs.existsSync(path.join(dest, ".mcp.json"))).toBe(true);
2086
+ expect(JSON.parse(fs.readFileSync(path.join(dest, ".mcp.json"), "utf8"))).toEqual({
2087
+ mcpServers: {},
2088
+ });
2089
+ });
2090
+
2091
+ it("keeps dest .mcp.json when already present", () => {
2092
+ const dest = tmp("grunt-mcp-keep-");
2093
+ fs.writeFileSync(
2094
+ path.join(dest, ".mcp.json"),
2095
+ `${JSON.stringify({ mcpServers: { tasks: { command: "npx" } } }, null, 2)}\n`,
2096
+ );
2097
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2098
+ expect(JSON.parse(fs.readFileSync(path.join(dest, ".mcp.json"), "utf8"))).toEqual({
2099
+ mcpServers: { tasks: { command: "npx" } },
2100
+ });
2101
+ });
2102
+
2103
+ it("throws when dest emit-maps fails", () => {
2104
+ emitMaps.mockReturnValue({ ok: false, error: "maps boom" });
2105
+ const dest = tmp("grunt-maps-fail-");
2106
+ expect(() =>
2107
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" }),
2108
+ ).toThrow("maps boom");
2109
+ emitMaps.mockReturnValue({ ok: false });
2110
+ const dest2 = tmp("grunt-maps-fail2-");
2111
+ expect(() =>
2112
+ init(dest2, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" }),
2113
+ ).toThrow("emit-maps failed");
2114
+ });
2115
+
2116
+ it("does not stamp package INDEX over dest; re-emits union", () => {
2117
+ const dest = tmp("grunt-index-keep-");
2118
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2119
+ fs.writeFileSync(path.join(dest, ".rulesync", "reference", "owned.md"), "# Owned\n\nDest-only.\n");
2120
+ fs.writeFileSync(
2121
+ path.join(dest, ".rulesync", "reference", "INDEX.md"),
2122
+ "stale grunt-only index\n",
2123
+ );
2124
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2125
+ const index = fs.readFileSync(path.join(dest, ".rulesync", "reference", "INDEX.md"), "utf8");
2126
+ expect(index).not.toBe("stale grunt-only index\n");
2127
+ expect(index).toMatch(/owned\.md/);
2128
+ expect(index).toMatch(/browser\.md/);
2129
+ });
2130
+
1882
2131
  it("onPhase merge then npm phases stop before exec", () => {
1883
2132
  const pkgRoot = stubPkgRoot();
1884
2133
  const dest = tmp("grunt-onphase-");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Multi-agent orchestrator SoT — deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
5
5
  "keywords": [
6
6
  "agents",
@@ -36,6 +36,7 @@
36
36
  "scripts/pipeline.mjs",
37
37
  "scripts/grunt-job.mjs",
38
38
  "scripts/parse-need.mjs",
39
+ "scripts/package-manager.mjs",
39
40
  "scripts/persist-handoff.mjs",
40
41
  "scripts/persist-implementation.mjs",
41
42
  "scripts/persist-tmp.mjs",
@@ -4,8 +4,15 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { parse as parseToml } from "smol-toml";
7
+ import { detectPackageManager, runScriptLine } from "./package-manager.mjs";
7
8
 
8
- const APPLY_HINT = "npm run sync:globals:apply";
9
+ function applyHint(workspaceRoot) {
10
+ const { manager } = detectPackageManager({
11
+ cwd: workspaceRoot || process.cwd(),
12
+ env: process.env,
13
+ });
14
+ return runScriptLine(manager, "sync:globals:apply");
15
+ }
9
16
  const PROJECT_REL = ".grok/config.toml";
10
17
  const HOME_REL = ".grok/config.toml";
11
18
 
@@ -33,16 +40,17 @@ function parseTomlFile(abs, label) {
33
40
  export function checkGlobals({ home, workspaceRoot } = {}) {
34
41
  const homeDir = home || process.env.HOME;
35
42
  const ws = workspaceRoot || process.cwd();
43
+ const hint = applyHint(ws);
36
44
  if (!homeDir) {
37
- return { ok: false, error: `HOME required; ${APPLY_HINT}` };
45
+ return { ok: false, error: `HOME required; ${hint}` };
38
46
  }
39
47
  const homeConfig = path.resolve(homeDir, HOME_REL);
40
48
  const homeParsed = parseTomlFile(homeConfig, "home ~/.grok/config.toml");
41
49
  if (!homeParsed.ok) {
42
50
  if (homeParsed.missing) {
43
- return { ok: false, error: `missing ~/.grok/config.toml; ${APPLY_HINT}` };
51
+ return { ok: false, error: `missing ~/.grok/config.toml; ${hint}` };
44
52
  }
45
- return { ok: false, error: `${homeParsed.error}; ${APPLY_HINT}` };
53
+ return { ok: false, error: `${homeParsed.error}; ${hint}` };
46
54
  }
47
55
  const agentName = homeParsed.value.agent && homeParsed.value.agent.name;
48
56
  const compaction =
@@ -50,7 +58,7 @@ export function checkGlobals({ home, workspaceRoot } = {}) {
50
58
  if (agentName !== "orchestrator" || compaction !== true) {
51
59
  return {
52
60
  ok: false,
53
- error: `home [agent].name must be orchestrator and [features].two_pass_compaction must be true; ${APPLY_HINT}`,
61
+ error: `home [agent].name must be orchestrator and [features].two_pass_compaction must be true; ${hint}`,
54
62
  };
55
63
  }
56
64