@lovrozagar/grunt 0.9.4 → 0.10.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/grunt.test.ts CHANGED
@@ -14,7 +14,9 @@ const detectPackageManager = vi.hoisted(() => vi.fn());
14
14
  const actualDetect = vi.hoisted(() => ({ fn: null as null | ((...args: unknown[]) => unknown) }));
15
15
  const isInteractive = vi.hoisted(() => vi.fn(() => false));
16
16
  const select = vi.hoisted(() => vi.fn());
17
+ const multiselect = vi.hoisted(() => vi.fn(async () => []));
17
18
  const confirm = vi.hoisted(() => vi.fn());
19
+ const selectedPacks = vi.hoisted(() => vi.fn(() => [] as string[]));
18
20
  const spinner = vi.hoisted(() =>
19
21
  vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
20
22
  );
@@ -46,10 +48,15 @@ vi.mock("../scripts/package-manager.mjs", async (importOriginal) => {
46
48
  vi.mock("./prompt.mjs", () => ({
47
49
  isInteractive,
48
50
  select,
51
+ multiselect,
49
52
  confirm,
50
53
  spinner,
51
54
  bailIfCancel,
52
55
  }));
56
+ vi.mock("../scripts/feature-packs.mjs", async (importOriginal) => {
57
+ const actual = await importOriginal<typeof import("../scripts/feature-packs.mjs")>();
58
+ return { ...actual, selectedPacks };
59
+ });
53
60
 
54
61
  import {
55
62
  PACKAGE_MANAGER_ASK,
@@ -68,7 +75,7 @@ Commands:
68
75
  sync-globals run grunt:sync:globals (dry-run; --apply to write)
69
76
  purge-mcps run grunt:purge:global-mcps (dry-run; --apply to write)
70
77
  doctor run grunt:doctor
71
- setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
78
+ setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser, jev)
72
79
  upgrade Self-update to the latest grunt, then re-init: copy owned files, prune retired names, print reserved skills
73
80
  map [dir] Code-only folder map (git-tracked, no files); dir for depth
74
81
  help Show this help
@@ -82,6 +89,7 @@ Flags:
82
89
  --apply Write for sync-globals / purge-mcps
83
90
  --host <id> sync-globals host
84
91
  --pm <name> npm | yarn | pnpm | bun (else lockfile, then how grunt was launched, then ask)
92
+ --packs <list> Optional packs: google-workspace,listen,speak,clasp or none
85
93
  `;
86
94
 
87
95
  const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -125,6 +133,10 @@ describe("start", () => {
125
133
  spinner.mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() }));
126
134
  bailIfCancel.mockReset();
127
135
  bailIfCancel.mockImplementation((v) => v);
136
+ multiselect.mockReset();
137
+ multiselect.mockResolvedValue([]);
138
+ selectedPacks.mockReset();
139
+ selectedPacks.mockReturnValue([]);
128
140
  });
129
141
 
130
142
  afterEach(() => {
@@ -659,6 +671,46 @@ describe("parseArgv --pm", () => {
659
671
  });
660
672
  });
661
673
 
674
+ it("TTY upgrade asks packs only", async () => {
675
+ isInteractive.mockReturnValue(true);
676
+ confirm.mockClear();
677
+ multiselect.mockClear();
678
+ init.mockClear();
679
+ multiselect.mockResolvedValue(["listen", "speak"]);
680
+ process.argv = ["node", "grunt", "upgrade", "--no-self-update"];
681
+ await start();
682
+ expect(confirm).not.toHaveBeenCalled();
683
+ expect(multiselect).toHaveBeenCalledWith(
684
+ expect.objectContaining({ message: "Optional packs", required: false, initialValues: [] }),
685
+ );
686
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
687
+ skipGlobals: false,
688
+ packageManager: "npm",
689
+ packs: ["listen", "speak"],
690
+ });
691
+ });
692
+
693
+ it("TTY init pre-checks a saved features file and skips the pack prompt when --packs is set", async () => {
694
+ isInteractive.mockReturnValue(true);
695
+ selectedPacks.mockReturnValue(["speak"]);
696
+ confirm.mockResolvedValue(true);
697
+ process.argv = ["node", "grunt", "init"];
698
+ await start();
699
+ expect(multiselect).toHaveBeenCalledWith(
700
+ expect.objectContaining({ initialValues: ["speak"], required: false }),
701
+ );
702
+ multiselect.mockClear();
703
+ confirm.mockClear();
704
+ init.mockClear();
705
+ process.argv = ["node", "grunt", "init", "--packs", "listen"];
706
+ await start();
707
+ expect(multiselect).not.toHaveBeenCalled();
708
+ expect(init).toHaveBeenCalledWith(
709
+ process.cwd(),
710
+ expect.objectContaining({ packs: ["listen"], applyGlobals: true }),
711
+ );
712
+ });
713
+
662
714
  it("missing, empty, flag, and unknown are pmError", () => {
663
715
  expect(parseArgv(["generate", "--pm"])).toMatchObject({ pmError: true });
664
716
  expect(parseArgv(["generate", "--pm="])).toMatchObject({ pmError: true });
@@ -666,3 +718,72 @@ describe("parseArgv --pm", () => {
666
718
  expect(parseArgv(["generate", "--pm", "deno"])).toMatchObject({ pmError: true });
667
719
  });
668
720
  });
721
+
722
+ describe("parseArgv --packs", () => {
723
+ it("pair, equals, and none", () => {
724
+ expect(parseArgv(["init", "--packs", "speak,listen"])).toMatchObject({
725
+ cmd: "init",
726
+ packs: ["listen", "speak"],
727
+ packsError: false,
728
+ });
729
+ expect(parseArgv(["init", "--packs=google-workspace,clasp"])).toMatchObject({
730
+ packs: ["google-workspace", "clasp"],
731
+ packsError: false,
732
+ });
733
+ expect(parseArgv(["init", "--packs", "none"])).toMatchObject({ packs: [], packsError: false });
734
+ expect(parseArgv(["init", "--packs="])).toMatchObject({ packs: [], packsError: false });
735
+ });
736
+
737
+ it("missing, flag, and unknown are packsError", () => {
738
+ expect(parseArgv(["init", "--packs"])).toMatchObject({ packsError: true });
739
+ expect(parseArgv(["init", "--packs", "--yes"])).toMatchObject({ packsError: true });
740
+ expect(parseArgv(["init", "--packs", "nope"])).toMatchObject({ packsError: true });
741
+ expect(parseArgv(["init", "--packs=nope"])).toMatchObject({ packsError: true });
742
+ });
743
+
744
+ it("unknown --packs writes usage", async () => {
745
+ const lines: string[] = [];
746
+ const stdoutWrite = process.stdout.write;
747
+ const exitCode = process.exitCode;
748
+ const argv = process.argv.slice();
749
+ process.stdout.write = ((buf: string | Uint8Array) => {
750
+ lines.push(String(buf));
751
+ return true;
752
+ }) as typeof process.stdout.write;
753
+ process.exitCode = 0;
754
+ process.argv = ["node", "grunt", "init", "--packs", "nope"];
755
+ init.mockClear();
756
+ try {
757
+ await start();
758
+ expect(lines.join("")).toBe(USAGE);
759
+ expect(process.exitCode).toBe(1);
760
+ expect(init).not.toHaveBeenCalled();
761
+ } finally {
762
+ process.stdout.write = stdoutWrite;
763
+ process.exitCode = exitCode;
764
+ process.argv = argv;
765
+ }
766
+ });
767
+
768
+ it("TTY upgrade with --packs skips the pack prompt", async () => {
769
+ isInteractive.mockReturnValue(true);
770
+ multiselect.mockClear();
771
+ confirm.mockClear();
772
+ init.mockClear();
773
+ process.argv = ["node", "grunt", "upgrade", "--no-self-update", "--packs", "listen"];
774
+ const stdoutWrite = process.stdout.write;
775
+ process.stdout.write = (() => true) as typeof process.stdout.write;
776
+ try {
777
+ await start();
778
+ expect(multiselect).not.toHaveBeenCalled();
779
+ expect(confirm).not.toHaveBeenCalled();
780
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
781
+ skipGlobals: false,
782
+ packageManager: "npm",
783
+ packs: ["listen"],
784
+ });
785
+ } finally {
786
+ process.stdout.write = stdoutWrite;
787
+ }
788
+ });
789
+ });
package/cli/init.mjs CHANGED
@@ -15,7 +15,13 @@ import {
15
15
  snapshotGuardedRoots,
16
16
  remergeGuardedRoots,
17
17
  withGuardedCheckInteriors,
18
+ mergeReferenceMarkdown,
18
19
  } from "../scripts/guarded-md.mjs"
20
+ import {
21
+ COMMANDS as PIPELINE_COMMANDS,
22
+ PIPELINE_CONFIG_REL,
23
+ inferPipelineSkip,
24
+ } from "../scripts/pipeline.mjs"
19
25
  import {
20
26
  PACKAGE_MANAGERS,
21
27
  UNKNOWN_PACKAGE_MANAGER,
@@ -25,6 +31,14 @@ import {
25
31
  runScriptArgs,
26
32
  } from "../scripts/package-manager.mjs"
27
33
  import { LAW_REL, emitMaps } from "../scripts/emit-maps.mjs"
34
+ import {
35
+ isUnselectedPackPath,
36
+ resolvePackSelection,
37
+ unselectedPackRels,
38
+ unselectedRefNames,
39
+ unselectedScriptNames,
40
+ writePacks,
41
+ } from "../scripts/feature-packs.mjs"
28
42
 
29
43
  export {
30
44
  GUARDED_ROOT_FILES,
@@ -41,6 +55,8 @@ export {
41
55
  remergeGuardedRoots,
42
56
  healGuardedRootFile,
43
57
  withGuardedCheckInteriors,
58
+ extraH2Sections,
59
+ mergeReferenceMarkdown,
44
60
  } from "../scripts/guarded-md.mjs"
45
61
 
46
62
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -70,9 +86,12 @@ export const PRODUCT_SCRIPTS = [
70
86
  "scrub-text-lib.mjs",
71
87
  "session-map.mjs",
72
88
  "sync-global-settings.mjs",
89
+ "board.mjs",
73
90
  "browser.mjs",
91
+ "feature-packs.mjs",
74
92
  "speak.mjs",
75
93
  "listen.mjs",
94
+ "jev.mjs",
76
95
  "google-workspace.mjs",
77
96
  "interactive.mjs",
78
97
  "prompt.mjs",
@@ -112,6 +131,7 @@ export const RESERVED_SKILLS = [
112
131
  "google-workspace",
113
132
  "handoff",
114
133
  "implement-plan",
134
+ "jev",
115
135
  "listen",
116
136
  "pickup",
117
137
  "speak",
@@ -147,6 +167,23 @@ export const LAUNCH_SCRIPTS = {
147
167
  gemini: "gemini --yolo",
148
168
  grok: "grok --yolo",
149
169
  }
170
+ /** Exact prior grunt defaults. A dest script equal to one of these migrates to the current value. */
171
+ export const HISTORICAL_SCRIPT_DEFAULTS = {
172
+ antigravity: ["antigravity"],
173
+ claude: ["claude"],
174
+ codex: ["codex"],
175
+ gemini: ["gemini"],
176
+ grok: ["grok"],
177
+ }
178
+ export const E2E_VITEST_CONFIGS = [
179
+ "vitest.e2e.config.ts",
180
+ "vitest.e2e.config.mts",
181
+ "vitest.e2e.config.js",
182
+ "vitest.e2e.config.mjs",
183
+ "vitest.e2e.config.cjs",
184
+ ]
185
+ export const REFERENCE_REL = ".rulesync/reference"
186
+ export { PIPELINE_CONFIG_REL }
150
187
  const OWNED_HOOK_FILES = [
151
188
  "scrub-spawn-prompt.mjs",
152
189
  "gate-fat-tools.mjs",
@@ -333,11 +370,62 @@ function extraOwnedSuffix(cur, newSrc) {
333
370
  return null
334
371
  }
335
372
 
336
- function mergeScriptValue(newSrc, cur) {
373
+ function mergeScriptValue(newSrc, cur, historical = []) {
337
374
  if (cur.startsWith(newSrc)) return cur
338
375
  const suffix = extraOwnedSuffix(cur, newSrc)
339
376
  if (suffix != null) return newSrc + suffix
340
- return newSrc
377
+ if (historical.includes(cur)) return newSrc
378
+ if (looksGruntOwnedPrefix(cur)) return newSrc
379
+ return cur
380
+ }
381
+
382
+ function historicalFor(key) {
383
+ return HISTORICAL_SCRIPT_DEFAULTS[key] || []
384
+ }
385
+
386
+ function hasE2eConfig(dest) {
387
+ return E2E_VITEST_CONFIGS.some((name) => fs.existsSync(path.join(dest, name)))
388
+ }
389
+
390
+ export function snapshotReferenceDocs(dest) {
391
+ const dir = path.join(dest, REFERENCE_REL)
392
+ const snap = {}
393
+ if (!fs.existsSync(dir)) return snap
394
+ for (const name of fs.readdirSync(dir)) {
395
+ if (!name.endsWith(".md") || GENERATED_MAP_FILES.has(name)) continue
396
+ const abs = path.join(dir, name)
397
+ if (!fs.statSync(abs).isFile()) continue
398
+ snap[name] = fs.readFileSync(abs, "utf8")
399
+ }
400
+ return snap
401
+ }
402
+
403
+ export function remergeReferenceDocs(dest, snap, pkgRoot, skip = new Set()) {
404
+ const pkgDir = path.join(pkgRoot, REFERENCE_REL)
405
+ const destDir = path.join(dest, REFERENCE_REL)
406
+ for (const [name, existing] of Object.entries(snap)) {
407
+ if (skip.has(name)) continue
408
+ const pkgFile = path.join(pkgDir, name)
409
+ if (!fs.existsSync(pkgFile)) continue
410
+ const incoming = fs.readFileSync(pkgFile, "utf8")
411
+ fs.mkdirSync(destDir, { recursive: true })
412
+ fs.writeFileSync(path.join(destDir, name), mergeReferenceMarkdown(existing, incoming))
413
+ }
414
+ }
415
+
416
+ export function seedPipelineConfig(dest, destPipelineSrc) {
417
+ if (!destPipelineSrc || !/export const COMMANDS/.test(destPipelineSrc)) return false
418
+ const abs = path.join(dest, PIPELINE_CONFIG_REL)
419
+ if (fs.existsSync(abs)) return false
420
+ const overlay = {}
421
+ for (const mode of Object.keys(PIPELINE_COMMANDS)) {
422
+ const skip = inferPipelineSkip(destPipelineSrc, mode)
423
+ if (skip.length) overlay[mode] = { skip }
424
+ }
425
+ if (!Object.keys(overlay).length) return false
426
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
427
+ fs.writeFileSync(abs, `${JSON.stringify(overlay, null, 2)}\n`)
428
+ return true
341
429
  }
342
430
 
343
431
  function rewritePackageManagerRunRefs(scripts, srcKeys) {
@@ -394,17 +482,19 @@ export function mergePackageJson(dest, pkgRoot) {
394
482
  const srcKeys = []
395
483
  for (const [k, v] of Object.entries(srcPkg.scripts || {})) {
396
484
  if (k === "test" || k.endsWith(":raw") || k in LAUNCH_SCRIPTS) continue
397
- srcKeys.push(k)
398
485
  const destKey = toGruntScriptName(k)
399
486
  const current = destPkg.scripts[destKey]
400
487
  const legacy = destPkg.scripts[k]
488
+ if (k === "test:e2e" && current == null && legacy == null && !hasE2eConfig(dest)) continue
489
+ srcKeys.push(k)
490
+ const historical = historicalFor(k)
401
491
  const ownedLegacy =
402
492
  legacy != null &&
403
493
  (legacy.startsWith(v) || extraOwnedSuffix(legacy, v) != null || looksGruntOwnedPrefix(legacy))
404
494
  if (current != null) {
405
- destPkg.scripts[destKey] = mergeScriptValue(v, current)
495
+ destPkg.scripts[destKey] = mergeScriptValue(v, current, historical)
406
496
  } else if (ownedLegacy) {
407
- destPkg.scripts[destKey] = mergeScriptValue(v, legacy)
497
+ destPkg.scripts[destKey] = mergeScriptValue(v, legacy, historical)
408
498
  } else {
409
499
  destPkg.scripts[destKey] = v
410
500
  }
@@ -419,7 +509,7 @@ export function mergePackageJson(dest, pkgRoot) {
419
509
  for (const [k, v] of Object.entries(LAUNCH_SCRIPTS)) {
420
510
  const destKey = toGruntScriptName(k)
421
511
  const current = destPkg.scripts[destKey]
422
- destPkg.scripts[destKey] = current != null ? mergeScriptValue(v, current) : v
512
+ destPkg.scripts[destKey] = current != null ? mergeScriptValue(v, current, historicalFor(k)) : v
423
513
  const staleKey = `grunt:yolo:${k}`
424
514
  if (destPkg.scripts[staleKey] === v) delete destPkg.scripts[staleKey]
425
515
  }
@@ -478,7 +568,7 @@ export function resolveInitPackageManager(dest, { packageManager, env = process.
478
568
  return detected.manager
479
569
  }
480
570
 
481
- export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase, packageManager, env = process.env } = {}) {
571
+ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase, packageManager, env = process.env, packs } = {}) {
482
572
  dest = path.resolve(dest)
483
573
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
484
574
  const skipGlobalsApply =
@@ -494,6 +584,9 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
494
584
  }
495
585
 
496
586
  const self = samePath(dest, pkgRoot)
587
+ const selection = self ? { packs: [], answered: false } : resolvePackSelection(dest, packs)
588
+ const selected = new Set(selection.packs)
589
+ const allowPack = (src) => !isUnselectedPackPath(path.relative(pkgRoot, src), selected)
497
590
 
498
591
  phase("merge", () => {
499
592
  const wsSkills = path.join(dest, WORKSPACE_SKILLS_REL)
@@ -507,18 +600,25 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
507
600
  }
508
601
  }
509
602
 
603
+ const refSnap = self ? {} : snapshotReferenceDocs(dest)
604
+ let destPipelineSrc = ""
605
+ if (!self) {
606
+ const destPipeline = path.join(dest, "scripts", "pipeline.mjs")
607
+ if (fs.existsSync(destPipeline)) destPipelineSrc = fs.readFileSync(destPipeline, "utf8")
608
+ }
609
+
510
610
  for (const dir of COPY_DIRS) {
511
611
  const src = path.join(pkgRoot, dir)
512
612
  const d = path.join(dest, dir)
513
613
  fs.mkdirSync(d, { recursive: true })
514
614
  if (samePath(src, d)) continue
515
615
  if (dir === ".claude") {
516
- copyTree(src, d, (s) => path.basename(s) !== "settings.json")
616
+ copyTree(src, d, (s) => path.basename(s) !== "settings.json" && allowPack(s))
517
617
  mergeClaudeSettings(dest, pkgRoot)
518
618
  } else if (dir === ".rulesync") {
519
- copyTree(src, d, (s) => !GENERATED_MAP_FILES.has(path.basename(s)))
619
+ copyTree(src, d, (s) => !GENERATED_MAP_FILES.has(path.basename(s)) && allowPack(s))
520
620
  } else {
521
- copyTree(src, d)
621
+ copyTree(src, d, allowPack)
522
622
  }
523
623
  }
524
624
 
@@ -529,7 +629,9 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
529
629
  }
530
630
 
531
631
  fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
632
+ const skipScripts = unselectedScriptNames(selected)
532
633
  for (const name of PRODUCT_SCRIPTS) {
634
+ if (skipScripts.has(name)) continue
533
635
  const src = path.join(pkgRoot, "scripts", name)
534
636
  const d = path.join(dest, "scripts", name)
535
637
  if (samePath(src, d)) continue
@@ -540,8 +642,14 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
540
642
  mergeGitignore(dest)
541
643
 
542
644
  if (!self) {
645
+ remergeReferenceDocs(dest, refSnap, pkgRoot, unselectedRefNames(selected))
646
+ seedPipelineConfig(dest, destPipelineSrc)
543
647
  mergePackageJson(dest, pkgRoot)
544
648
  copyRootIfMissing(dest, pkgRoot)
649
+ for (const rel of unselectedPackRels(selected)) {
650
+ fs.rmSync(path.join(dest, rel), { recursive: true, force: true })
651
+ }
652
+ if (selection.answered) writePacks(dest, selection.packs)
545
653
  emitDestMaps(dest)
546
654
  }
547
655
  })