@lovrozagar/grunt 0.9.1 → 0.9.2

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.
@@ -4,9 +4,8 @@
4
4
  Fail-open: parse error, missing rtk, timeout, no rewrite → empty stdout, exit 0.
5
5
  Keep the full toolInput (Grok needs `description`); only replace `command`.
6
6
  */
7
- "use strict";
8
-
9
- const { spawnSync } = require("node:child_process");
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
10
9
 
11
10
  const RTK_TIMEOUT_MS = 3000;
12
11
 
@@ -108,7 +107,6 @@ function rewrite(cmd) {
108
107
 
109
108
  /** Read one JSON value (Python json.load): stop when parseable, do not wait for EOF. */
110
109
  function readJsonValue() {
111
- const fs = require("node:fs");
112
110
  let buf = Buffer.alloc(0);
113
111
  const tmp = Buffer.alloc(8192);
114
112
  for (;;) {
package/bin/grunt.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { start } from "../cli/grunt.mjs"
3
3
 
4
- start().catch(() => {
4
+ start().catch((err) => {
5
+ process.stderr.write(`grunt: ${err?.message ?? err}\n`)
5
6
  process.exit(1)
6
7
  })
package/cli/grunt.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process"
1
+ import { execFileSync } from "./spawn.mjs"
2
2
  import { readFileSync } from "node:fs"
3
3
  import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
package/cli/grunt.test.ts CHANGED
@@ -30,7 +30,7 @@ vi.mock("./init.mjs", () => ({
30
30
  RESERVED_SKILLS: ["browser", "tmp", "write-plan"],
31
31
  GRUNT_NPM_PREFIX: "grunt:",
32
32
  }));
33
- vi.mock("node:child_process", () => ({ execFileSync }));
33
+ vi.mock("./spawn.mjs", () => ({ execFileSync }));
34
34
  vi.mock("./self-update.mjs", () => ({ selfUpdate }));
35
35
  vi.mock("../scripts/folder-map.mjs", () => ({ mapCommand }));
36
36
  vi.mock("../scripts/package-manager.mjs", async (importOriginal) => {
package/cli/init.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process"
1
+ import { execFileSync } from "./spawn.mjs"
2
2
  import fs from "node:fs"
3
3
  import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
@@ -53,6 +53,7 @@ export const PRODUCT_SCRIPTS = [
53
53
  "guarded-md.mjs",
54
54
  "guarded-roots.mjs",
55
55
  "emit-mcp-policy.mjs",
56
+ "jsonc.mjs",
56
57
  "folder-map.mjs",
57
58
  "gate-fat-tools.mjs",
58
59
  "hooks-union.mjs",
@@ -286,7 +287,8 @@ export function mergeClaudeSettings(destRoot, pkgRoot) {
286
287
  merged.push(item)
287
288
  }
288
289
  }
289
- destPerms[key] = merged
290
+ // No empty list the source never had, so a re-init does not rewrite the file.
291
+ if (merged.length || key in destPerms) destPerms[key] = merged
290
292
  }
291
293
  destSettings.permissions = destPerms
292
294
 
@@ -433,6 +435,21 @@ export function mergePackageJson(dest, pkgRoot) {
433
435
  fs.writeFileSync(destPath, `${JSON.stringify(destPkg, null, 2)}\n`)
434
436
  }
435
437
 
438
+ /**
439
+ * Recursive overwrite copy on readdir/readFile/writeFile. Not fs.cpSync: it runs in
440
+ * native code, so Yarn PnP (`yarn dlx`, package inside a zip) cannot serve it.
441
+ */
442
+ export function copyTree(src, dest, filter = () => true) {
443
+ if (!filter(src)) return
444
+ const st = fs.statSync(src)
445
+ if (st.isDirectory()) {
446
+ fs.mkdirSync(dest, { recursive: true })
447
+ for (const name of fs.readdirSync(src)) copyTree(path.join(src, name), path.join(dest, name), filter)
448
+ return
449
+ }
450
+ fs.writeFileSync(dest, fs.readFileSync(src), { mode: st.mode })
451
+ }
452
+
436
453
  function copyRootIfMissing(dest, pkgRoot) {
437
454
  for (const file of COPY_ROOT_IF_MISSING) {
438
455
  const src = path.join(pkgRoot, file)
@@ -496,20 +513,12 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
496
513
  fs.mkdirSync(d, { recursive: true })
497
514
  if (samePath(src, d)) continue
498
515
  if (dir === ".claude") {
499
- fs.cpSync(src, d, {
500
- recursive: true,
501
- force: true,
502
- filter: (s) => path.basename(s) !== "settings.json",
503
- })
516
+ copyTree(src, d, (s) => path.basename(s) !== "settings.json")
504
517
  mergeClaudeSettings(dest, pkgRoot)
505
518
  } else if (dir === ".rulesync") {
506
- fs.cpSync(src, d, {
507
- recursive: true,
508
- force: true,
509
- filter: (s) => !GENERATED_MAP_FILES.has(path.basename(s)),
510
- })
519
+ copyTree(src, d, (s) => !GENERATED_MAP_FILES.has(path.basename(s)))
511
520
  } else {
512
- fs.cpSync(src, d, { recursive: true, force: true })
521
+ copyTree(src, d)
513
522
  }
514
523
  }
515
524
 
@@ -524,9 +533,7 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
524
533
  const src = path.join(pkgRoot, "scripts", name)
525
534
  const d = path.join(dest, "scripts", name)
526
535
  if (samePath(src, d)) continue
527
- const st = fs.statSync(src)
528
- if (st.isDirectory()) fs.cpSync(src, d, { recursive: true, force: true })
529
- else fs.copyFileSync(src, d)
536
+ copyTree(src, d)
530
537
  }
531
538
 
532
539
  fs.mkdirSync(path.join(dest, ".tmp"), { recursive: true })
package/cli/init.test.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  MAX_GUARDED_MARKDOWN_BYTES,
21
21
  PRODUCT_SCRIPTS,
22
22
  composeGuardedMarkdown,
23
+ copyTree,
23
24
  destAlreadyInited,
24
25
  extractGruntBody,
25
26
  extractUserMarkdown,
@@ -835,6 +836,36 @@ describe("snapshot/remerge guarded roots", () => {
835
836
  });
836
837
  });
837
838
 
839
+ describe("copyTree", () => {
840
+ it("copies nested files, overwrites existing ones, and honors the filter", () => {
841
+ const src = tmp("copytree-src-");
842
+ fs.mkdirSync(path.join(src, "a", "b"), { recursive: true });
843
+ fs.writeFileSync(path.join(src, "a", "b", "keep.txt"), "new");
844
+ fs.writeFileSync(path.join(src, "a", "skip.txt"), "skip");
845
+ fs.writeFileSync(path.join(src, "top.txt"), "top");
846
+ const dest = tmp("copytree-dest-");
847
+ fs.mkdirSync(path.join(dest, "a", "b"), { recursive: true });
848
+ fs.writeFileSync(path.join(dest, "a", "b", "keep.txt"), "old");
849
+ fs.writeFileSync(path.join(dest, "mine.txt"), "mine");
850
+
851
+ copyTree(src, dest, (s) => path.basename(s) !== "skip.txt");
852
+
853
+ expect(fs.readFileSync(path.join(dest, "a", "b", "keep.txt"), "utf8")).toBe("new");
854
+ expect(fs.readFileSync(path.join(dest, "top.txt"), "utf8")).toBe("top");
855
+ expect(fs.existsSync(path.join(dest, "a", "skip.txt"))).toBe(false);
856
+ expect(fs.readFileSync(path.join(dest, "mine.txt"), "utf8")).toBe("mine");
857
+ });
858
+
859
+ it("never calls fs.cpSync (Yarn PnP zips cannot serve it)", () => {
860
+ const cp = vi.spyOn(fs, "cpSync");
861
+ const src = tmp("copytree-nocp-");
862
+ fs.writeFileSync(path.join(src, "f.txt"), "x");
863
+ copyTree(src, tmp("copytree-nocp-dest-"));
864
+ expect(cp).not.toHaveBeenCalled();
865
+ cp.mockRestore();
866
+ });
867
+ });
868
+
838
869
  describe("mergeClaudeSettings", () => {
839
870
  it("dest .claude/settings.json absent: plain copy", () => {
840
871
  const pkgRoot = stubPkgRoot();
@@ -846,6 +877,30 @@ describe("mergeClaudeSettings", () => {
846
877
  expect(out).toEqual(SRC_CLAUDE_SETTINGS);
847
878
  });
848
879
 
880
+ it("re-merge is a byte-for-byte no-op (no empty allow added)", () => {
881
+ const pkgRoot = stubPkgRoot();
882
+ const destRoot = tmp("settings-idempotent-");
883
+ mergeClaudeSettings(destRoot, pkgRoot);
884
+ const settingsPath = path.join(destRoot, ".claude", "settings.json");
885
+ const first = fs.readFileSync(settingsPath, "utf8");
886
+ mergeClaudeSettings(destRoot, pkgRoot);
887
+ expect(fs.readFileSync(settingsPath, "utf8")).toBe(first);
888
+ expect(JSON.parse(first).permissions).not.toHaveProperty("allow");
889
+ });
890
+
891
+ it("keeps an empty allow list the consumer wrote", () => {
892
+ const pkgRoot = stubPkgRoot();
893
+ const destRoot = tmp("settings-empty-allow-");
894
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
895
+ fs.writeFileSync(
896
+ path.join(destRoot, ".claude", "settings.json"),
897
+ JSON.stringify({ permissions: { allow: [] } }),
898
+ );
899
+ mergeClaudeSettings(destRoot, pkgRoot);
900
+ const out = JSON.parse(fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"));
901
+ expect(out.permissions.allow).toEqual([]);
902
+ });
903
+
849
904
  it("malformed dest JSON throws with the path in the message", () => {
850
905
  const pkgRoot = stubPkgRoot();
851
906
  const destRoot = tmp("settings-malformed-");
@@ -2263,6 +2318,8 @@ const PARENT_IMPORT_RE =
2263
2318
  /(?:from\s+|import\s*\(\s*)["'`](\.\.[^"'`]*)["'`]/g;
2264
2319
  const RELATIVE_IMPORT_RE =
2265
2320
  /(?:from\s+|import\s*\(\s*)["'`](\.[^"'`]+)["'`]/g;
2321
+ const ANY_IMPORT_RE =
2322
+ /^\s*(?:import|export)\b[\w\s{},*$]*?(?:\bfrom\s*)?["'`]([^"'`]+)["'`]|\bimport\s*\(\s*["'`]([^"'`]+)["'`]/gm;
2266
2323
 
2267
2324
  function relativeSpecs(text: string, re: RegExp) {
2268
2325
  const out: string[] = [];
@@ -2280,6 +2337,29 @@ describe("product scripts are consumer-safe", () => {
2280
2337
  }
2281
2338
  });
2282
2339
 
2340
+ it("bin graph imports only builtins and runtime dependencies (bunx/npx skip devDependencies)", () => {
2341
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
2342
+ const runtime = new Set(Object.keys(pkg.dependencies ?? {}));
2343
+ const seen = new Set<string>();
2344
+ const bad: string[] = [];
2345
+ const walk = (file: string) => {
2346
+ if (seen.has(file)) return;
2347
+ seen.add(file);
2348
+ const text = fs.readFileSync(file, "utf8");
2349
+ for (const m of text.matchAll(ANY_IMPORT_RE)) {
2350
+ const spec = m[1] ?? m[2];
2351
+ if (spec.startsWith(".")) walk(path.resolve(path.dirname(file), spec));
2352
+ else if (!spec.startsWith("node:")) {
2353
+ const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
2354
+ if (!runtime.has(name)) bad.push(`${path.relative(PKG_ROOT, file)} -> ${spec}`);
2355
+ }
2356
+ }
2357
+ };
2358
+ walk(path.join(PKG_ROOT, "bin", "grunt.js"));
2359
+ expect(seen.has(path.join(PKG_ROOT, "scripts", "folder-map.mjs"))).toBe(true);
2360
+ expect(bad).toEqual([]);
2361
+ });
2362
+
2283
2363
  it("do not parent-import (from or import()) — init copies scripts/ only", () => {
2284
2364
  for (const name of PRODUCT_FILES) {
2285
2365
  const text = fs.readFileSync(path.join(PKG_ROOT, "scripts", name), "utf8");
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process"
1
+ import { spawnSync } from "./spawn.mjs"
2
2
  import fs from "node:fs"
3
3
  import path from "node:path"
4
4
  import { GRUNT_PACKAGE, samePath } from "./init.mjs"
@@ -82,14 +82,16 @@ export async function selfUpdate({
82
82
  }
83
83
 
84
84
  const bin = path.join(cwd, "node_modules", GRUNT_PACKAGE, "bin", "grunt.js")
85
- if (!fs.existsSync(bin)) {
85
+ const opts = { cwd, stdio: "inherit", env: { ...env, [REEXEC_ENV]: "1" } }
86
+ let child
87
+ if (fs.existsSync(bin)) {
88
+ child = run(process.execPath, [bin, ...argv], opts)
89
+ } else if (fs.existsSync(path.join(cwd, ".pnp.cjs"))) {
90
+ // Yarn Plug'n'Play has no node_modules; yarn resolves the bin from the updated .pnp.cjs.
91
+ child = run("yarn", ["grunt", ...argv], opts)
92
+ } else {
86
93
  log(`grunt: ${bin} not found after install; upgrading with ${currentVersion}`)
87
94
  return { reexeced: false }
88
95
  }
89
- const child = run(process.execPath, [bin, ...argv], {
90
- cwd,
91
- stdio: "inherit",
92
- env: { ...env, [REEXEC_ENV]: "1" },
93
- })
94
96
  return { reexeced: true, status: child.status ?? 1 }
95
97
  }
@@ -187,6 +187,18 @@ describe("selfUpdate", () => {
187
187
  await expect(selfUpdate({ ...opts, run })).rejects.toThrow(/install/);
188
188
  });
189
189
 
190
+ it("re-execs through yarn under Plug'n'Play (no node_modules)", async () => {
191
+ const { opts, run, log, cwd } = setup({ withBin: false, runStatus: 0 });
192
+ fs.writeFileSync(path.join(cwd, ".pnp.cjs"), "");
193
+ expect(await selfUpdate(opts)).toEqual({ reexeced: true, status: 0 });
194
+ const [cmd, args, o] = run.mock.calls[1];
195
+ expect(cmd).toBe("yarn");
196
+ expect(args).toEqual(["grunt", "upgrade", "--skip-globals"]);
197
+ expect(o).toMatchObject({ cwd, stdio: "inherit" });
198
+ expect(o.env[REEXEC_ENV]).toBe("1");
199
+ expect(log).toHaveBeenCalledTimes(1);
200
+ });
201
+
190
202
  it("warns and continues when the installed bin is missing", async () => {
191
203
  const { opts, run, log } = setup({ withBin: false });
192
204
  expect(await selfUpdate(opts)).toEqual({ reexeced: false });
package/cli/spawn.mjs ADDED
@@ -0,0 +1,20 @@
1
+ import crossSpawn from "cross-spawn"
2
+
3
+ /**
4
+ * child_process twins for starting package managers. On Windows npm, pnpm, and yarn are
5
+ * .cmd shims that child_process cannot start without a shell (spawnSync npm ENOENT);
6
+ * cross-spawn resolves and escapes them. Elsewhere it is plain spawnSync.
7
+ */
8
+ export function spawnSync(cmd, args, opts) {
9
+ return crossSpawn.sync(cmd, args, opts)
10
+ }
11
+
12
+ /** execFileSync semantics: throw unless the command exits 0. */
13
+ export function execFileSync(cmd, args, opts) {
14
+ const r = spawnSync(cmd, args, opts)
15
+ if (r.error) throw r.error
16
+ if (r.status !== 0) {
17
+ throw new Error(`${cmd} ${args.join(" ")} exited with ${r.status ?? r.signal}`)
18
+ }
19
+ return r.stdout
20
+ }
@@ -0,0 +1,34 @@
1
+ import crossSpawn from "cross-spawn";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { execFileSync, spawnSync } from "./spawn.mjs";
4
+
5
+ const node = process.execPath;
6
+
7
+ describe("spawnSync", () => {
8
+ it("returns status and output like child_process.spawnSync", () => {
9
+ const r = spawnSync(node, ["-e", "process.stdout.write('hi'); process.exit(3)"], { encoding: "utf8" });
10
+ expect(r.status).toBe(3);
11
+ expect(r.stdout).toBe("hi");
12
+ });
13
+ });
14
+
15
+ describe("execFileSync", () => {
16
+ it("returns stdout on exit 0", () => {
17
+ expect(execFileSync(node, ["-e", "process.stdout.write('ok')"], { encoding: "utf8" })).toBe("ok");
18
+ });
19
+
20
+ it("throws with the exit code on a nonzero exit", () => {
21
+ expect(() => execFileSync(node, ["-e", "process.exit(2)"], {})).toThrow(/exited with 2$/);
22
+ });
23
+
24
+ // Mocked: Windows has no signals, so a real kill reports an exit code there.
25
+ it("throws with the signal when the child is killed", () => {
26
+ const sync = vi.spyOn(crossSpawn, "sync").mockReturnValueOnce({ status: null, signal: "SIGTERM" } as never);
27
+ expect(() => execFileSync("npm", ["install"], {})).toThrow(/^npm install exited with SIGTERM$/);
28
+ sync.mockRestore();
29
+ });
30
+
31
+ it("throws the spawn error for a missing command", () => {
32
+ expect(() => execFileSync("grunt-no-such-command-e2e", [], {})).toThrow(/ENOENT/);
33
+ });
34
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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",
@@ -31,6 +31,7 @@
31
31
  "scripts/guarded-md.mjs",
32
32
  "scripts/guarded-roots.mjs",
33
33
  "scripts/emit-mcp-policy.mjs",
34
+ "scripts/jsonc.mjs",
34
35
  "scripts/folder-map.mjs",
35
36
  "scripts/gate-fat-tools.mjs",
36
37
  "scripts/hooks-union.mjs",
@@ -74,7 +75,8 @@
74
75
  "access": "public"
75
76
  },
76
77
  "dependencies": {
77
- "@clack/prompts": "^0.11.0"
78
+ "@clack/prompts": "^0.11.0",
79
+ "cross-spawn": "^7.0.6"
78
80
  },
79
81
  "scripts": {
80
82
  "rulesync:generate": "node ./scripts/guarded-roots.mjs generate",
@@ -95,14 +97,17 @@
95
97
  "codex": "codex --dangerously-bypass-approvals-and-sandbox",
96
98
  "gemini": "gemini --yolo",
97
99
  "grok": "grok --yolo",
98
- "test": "vitest run --coverage"
100
+ "test": "vitest run --coverage",
101
+ "test:e2e": "vitest run -c vitest.e2e.config.ts"
99
102
  },
100
103
  "devDependencies": {
101
104
  "@types/node": "^24.13.3",
102
105
  "@vitest/coverage-v8": "^3.2.7",
103
106
  "rulesync": "16.17.0",
104
107
  "smol-toml": "^1.8.0",
108
+ "tar": "^7.5.22",
105
109
  "typescript": "^5.9.3",
110
+ "verdaccio": "^6.10.5",
106
111
  "vitest": "^3.2.7"
107
112
  },
108
113
  "engines": {
@@ -4,6 +4,7 @@ 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, stringify as stringifyToml } from "smol-toml";
7
+ import { stripJsonc } from "./jsonc.mjs";
7
8
 
8
9
  export const POLICY_REL = ".rulesync/mcp-policy.jsonc";
9
10
  const GROK_REL = ".grok/config.toml";
@@ -22,12 +23,6 @@ function isPlainObject(v) {
22
23
  return v != null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
23
24
  }
24
25
 
25
- export function stripJsonc(text) {
26
- return String(text ?? "")
27
- .replace(/\/\*[\s\S]*?\*\//g, "")
28
- .replace(/\/\/.*$/gm, "");
29
- }
30
-
31
26
  export function parseArgv(argv) {
32
27
  const args = Array.isArray(argv) ? argv : [];
33
28
  let check = false;
@@ -15,7 +15,7 @@ import { spawnSync } from "node:child_process";
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
- import { stripJsonc } from "./emit-mcp-policy.mjs";
18
+ import { stripJsonc } from "./jsonc.mjs";
19
19
 
20
20
  export const DEFAULTS = Object.freeze({
21
21
  budget: 5000,
@@ -0,0 +1,7 @@
1
+ /** Dependency-free JSONC helpers. The published CLI imports this, so keep it to node builtins. */
2
+
3
+ export function stripJsonc(text) {
4
+ return String(text ?? "")
5
+ .replace(/\/\*[\s\S]*?\*\//g, "")
6
+ .replace(/\/\/.*$/gm, "");
7
+ }