@lovrozagar/grunt 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ targets:
8
8
  globs:
9
9
  - "**/*"
10
10
  ---
11
- You are the session agent. Do the work. Use en-US unless asked. Write concise complete sentences with natural grammar. Skip filler and fluff. Ship optimal solutions only; rewrite if not. Flag blockers. Do not monkey-patch. Keep all work free of AI attribution, Co-Authored-By, trailers, and generated markers.
11
+ You are the session agent. Do the work. Use en-US unless asked. Write concise complete sentences with natural grammar. Skip filler and fluff. Ship optimal solutions only; rewrite if not. Flag blockers. Do not monkey-patch. When advising, recommend the optimal choice based on research, data, and reasoning, not the user's preference. Change it only for new evidence or a flaw found in it. Pushback or a leading question is not a reason to switch; keep the recommendation and explain why. Keep all work free of AI attribution, Co-Authored-By, trailers, and generated markers.
12
12
 
13
13
  Size first. Straight shot: do it. Else read `.rulesync/reference/scope.md`.
14
14
  Default `/auto`: keep going; ask on blockers. `/ask`: one step, then ask.
package/AGENTS.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- grunt:begin -->
2
- You are the session agent. Do the work. Use en-US unless asked. Write concise complete sentences with natural grammar. Skip filler and fluff. Ship optimal solutions only; rewrite if not. Flag blockers. Do not monkey-patch. Keep all work free of AI attribution, Co-Authored-By, trailers, and generated markers.
2
+ You are the session agent. Do the work. Use en-US unless asked. Write concise complete sentences with natural grammar. Skip filler and fluff. Ship optimal solutions only; rewrite if not. Flag blockers. Do not monkey-patch. When advising, recommend the optimal choice based on research, data, and reasoning, not the user's preference. Change it only for new evidence or a flaw found in it. Pushback or a leading question is not a reason to switch; keep the recommendation and explain why. Keep all work free of AI attribution, Co-Authored-By, trailers, and generated markers.
3
3
 
4
4
  Size first. Straight shot: do it. Else read `.rulesync/reference/scope.md`.
5
5
  Default `/auto`: keep going; ask on blockers. `/ask`: one step, then ask.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-10-04
11
+
12
+ ### Fixed
13
+
14
+ - 0.9.0 was tagged but not published because CI failed the 100% coverage gate on `cli/self-update.mjs`. 0.9.1 ships the 0.9.0 changes
15
+
16
+ ## [0.9.0] - 2026-10-04
17
+
18
+ ### Added
19
+
20
+ - `grunt upgrade` updates grunt itself: it checks the registry, installs the newer `@lovrozagar/grunt` as a devDependency with the detected package manager, and re-runs with the new bin. `--no-self-update` opts out; offline falls back to the running version
21
+ - Root rule: recommend the optimal choice from research, data, and reasoning, and change it only for new evidence or a found flaw, not for pushback or a leading question
22
+
10
23
  ## [0.8.3] - 2026-10-03
11
24
 
12
25
  ### Changed
package/README.md CHANGED
@@ -84,7 +84,7 @@ Rulesync schema doctor is separate: `grunt:rulesync:doctor` (or SoT `rulesync:do
84
84
  - `purge-mcps` → run `grunt:purge:global-mcps` (dry-run); `--apply` → `grunt:purge:global-mcps:apply`
85
85
  - `doctor` → run `grunt:doctor` (`grunt doctor` / `node scripts/doctor.mjs` stay). Rulesync schema: `grunt:rulesync:doctor`
86
86
  - `setup` → run `grunt:setup` (`node scripts/setup.mjs`) — handheld speak / listen / google-workspace / browser. TTY walks each; flags for non-TTY keys/`--creds`
87
- - `upgrade` → same merge as init for an already-inited repo: copy owned trees/scripts, prune retired grunt-owned names, print reserved skill names
87
+ - `upgrade` → install the latest grunt as a devDependency and re-run with it, then the same merge as init for an already-inited repo: copy owned trees/scripts, prune retired grunt-owned names, print reserved skill names
88
88
  - `map [dir]` → code-only folder map (`scripts/folder-map.mjs`); no package manager needed. See [Folder map](#folder-map)
89
89
  - `help`
90
90
  - `version`
@@ -92,6 +92,7 @@ Rulesync schema doctor is separate: `grunt:rulesync:doctor` (or SoT `rulesync:do
92
92
  ### Flags
93
93
 
94
94
  - `--skip-globals` — skip `sync:globals:apply` on init
95
+ - `--no-self-update` — `upgrade` keeps the installed grunt version
95
96
  - `--yes` / `-y` / `--non-interactive` — no TTY menu; default command still `init`. Not `--apply`
96
97
  - `--apply` — write for `sync-globals` / `purge-mcps`
97
98
  - `--host <id>` — `sync-globals --host <id>`
@@ -116,12 +117,14 @@ Rulesync schema doctor is separate: `grunt:rulesync:doctor` (or SoT `rulesync:do
116
117
  Already-inited consumer (0.5.x → 0.6, and later):
117
118
 
118
119
  ```
119
- npm i -D @lovrozagar/grunt@latest && npm exec grunt upgrade
120
- pnpm add -D @lovrozagar/grunt@latest && pnpm exec grunt upgrade
121
- yarn add -D @lovrozagar/grunt@latest && yarn grunt upgrade
122
- bun add -D @lovrozagar/grunt@latest && bunx grunt upgrade
120
+ npm exec grunt upgrade
121
+ pnpm exec grunt upgrade
122
+ yarn grunt upgrade
123
+ bunx grunt upgrade
123
124
  ```
124
125
 
126
+ `upgrade` checks the registry (`npm_config_registry`, else npmjs) for the latest `@lovrozagar/grunt`. When it is newer than the running copy, `upgrade` adds it as a devDependency with the detected package manager (`-w` at a pnpm workspace root), then re-runs the same command with the installed bin so the merge uses the new package. Offline or a failed check prints one warning and upgrades with the running version. `--no-self-update` skips the check. The grunt repo itself and local builds newer than the registry never self-update.
127
+
125
128
  `upgrade` is init plus a reserved-names print. Same merge, prune, and globals-skip rules as Init. New skills/scripts appear because they are in the package copy list. Dropped grunt-owned files disappear only if they are on the retired lists (or a reserved skill this package no longer ships). Do not expect a blind dest-dir mirror-delete; that would wipe consumer extras.
126
129
 
127
130
  ## Agents
package/cli/grunt.mjs CHANGED
@@ -4,6 +4,7 @@ import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import { destAlreadyInited, init, RESERVED_SKILLS, toGruntScriptName } from "./init.mjs"
6
6
  import { confirm, isInteractive, select, spinner } from "./prompt.mjs"
7
+ import { selfUpdate } from "./self-update.mjs"
7
8
  import {
8
9
  PACKAGE_MANAGER_ASK,
9
10
  PACKAGE_MANAGER_OPTIONS,
@@ -28,13 +29,14 @@ Commands:
28
29
  purge-mcps run grunt:purge:global-mcps (dry-run; --apply to write)
29
30
  doctor run grunt:doctor
30
31
  setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
31
- upgrade Re-init: copy owned files, prune retired grunt-owned names, print reserved skills
32
+ upgrade Self-update to the latest grunt, then re-init: copy owned files, prune retired names, print reserved skills
32
33
  map [dir] Code-only folder map (git-tracked, no files); dir for depth
33
34
  help Show this help
34
35
  version Print package version
35
36
 
36
37
  Flags:
37
38
  --skip-globals Skip sync:globals:apply (auto-skipped when already initialized)
39
+ --no-self-update upgrade: keep the installed grunt version
38
40
  --yes, -y Non-interactive (not --apply)
39
41
  --non-interactive Same as --yes
40
42
  --apply Write for sync-globals / purge-mcps
@@ -86,6 +88,7 @@ function pmValueOk(v) {
86
88
 
87
89
  export function parseArgv(argv) {
88
90
  let skipGlobals = false
91
+ let noSelfUpdate = false
89
92
  let apply = false
90
93
  let host
91
94
  let hostError = false
@@ -98,6 +101,10 @@ export function parseArgv(argv) {
98
101
  skipGlobals = true
99
102
  continue
100
103
  }
104
+ if (a === "--no-self-update") {
105
+ noSelfUpdate = true
106
+ continue
107
+ }
101
108
  if (YES_FLAGS.has(a)) continue
102
109
  if (a === "--apply") {
103
110
  apply = true
@@ -136,7 +143,17 @@ export function parseArgv(argv) {
136
143
  }
137
144
  positionals.push(a)
138
145
  }
139
- return { cmd: positionals[0], args: positionals.slice(1), skipGlobals, apply, host, hostError, pm, pmError }
146
+ return {
147
+ cmd: positionals[0],
148
+ args: positionals.slice(1),
149
+ skipGlobals,
150
+ noSelfUpdate,
151
+ apply,
152
+ host,
153
+ hostError,
154
+ pm,
155
+ pmError,
156
+ }
140
157
  }
141
158
 
142
159
  function hostExtra(host) {
@@ -238,6 +255,19 @@ async function dispatch(cmd, flags, interactive, pm) {
238
255
  return
239
256
  }
240
257
  if (cmd === "upgrade") {
258
+ if (!flags.noSelfUpdate) {
259
+ const updated = await selfUpdate({
260
+ cwd: process.cwd(),
261
+ pkgRoot: PKG_ROOT,
262
+ currentVersion: pkgVersion(),
263
+ pm,
264
+ argv: process.argv.slice(2),
265
+ })
266
+ if (updated.reexeced) {
267
+ process.exitCode = updated.status
268
+ return
269
+ }
270
+ }
241
271
  await runInit(process.cwd(), {
242
272
  skipGlobals: flags.skipGlobals,
243
273
  interactive: false,
package/cli/grunt.test.ts CHANGED
@@ -19,6 +19,7 @@ const spinner = vi.hoisted(() =>
19
19
  vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
20
20
  );
21
21
  const bailIfCancel = vi.hoisted(() => vi.fn((v) => v));
22
+ const selfUpdate = vi.hoisted(() => vi.fn(async () => ({ reexeced: false })));
22
23
  const mapCommand = vi.hoisted(() => vi.fn((_cwd: string, dir: string) => `map:${dir}\n`));
23
24
 
24
25
  vi.mock("./init.mjs", () => ({
@@ -30,6 +31,7 @@ vi.mock("./init.mjs", () => ({
30
31
  GRUNT_NPM_PREFIX: "grunt:",
31
32
  }));
32
33
  vi.mock("node:child_process", () => ({ execFileSync }));
34
+ vi.mock("./self-update.mjs", () => ({ selfUpdate }));
33
35
  vi.mock("../scripts/folder-map.mjs", () => ({ mapCommand }));
34
36
  vi.mock("../scripts/package-manager.mjs", async (importOriginal) => {
35
37
  const actual = await importOriginal<typeof import("../scripts/package-manager.mjs")>();
@@ -67,13 +69,14 @@ Commands:
67
69
  purge-mcps run grunt:purge:global-mcps (dry-run; --apply to write)
68
70
  doctor run grunt:doctor
69
71
  setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
70
- upgrade Re-init: copy owned files, prune retired grunt-owned names, print reserved skills
72
+ upgrade Self-update to the latest grunt, then re-init: copy owned files, prune retired names, print reserved skills
71
73
  map [dir] Code-only folder map (git-tracked, no files); dir for depth
72
74
  help Show this help
73
75
  version Print package version
74
76
 
75
77
  Flags:
76
78
  --skip-globals Skip sync:globals:apply (auto-skipped when already initialized)
79
+ --no-self-update upgrade: keep the installed grunt version
77
80
  --yes, -y Non-interactive (not --apply)
78
81
  --non-interactive Same as --yes
79
82
  --apply Write for sync-globals / purge-mcps
@@ -108,6 +111,8 @@ describe("start", () => {
108
111
  shouldAutoSkipGlobals.mockReset();
109
112
  shouldAutoSkipGlobals.mockReturnValue(false);
110
113
  execFileSync.mockReset();
114
+ selfUpdate.mockReset();
115
+ selfUpdate.mockResolvedValue({ reexeced: false });
111
116
  detectPackageManager.mockReset();
112
117
  detectPackageManager.mockImplementation((...args: unknown[]) =>
113
118
  (actualDetect.fn as (...a: unknown[]) => unknown)(...args),
@@ -322,6 +327,38 @@ describe("start", () => {
322
327
  });
323
328
  expect(chunks.join("")).toBe("reserved: browser tmp write-plan\n");
324
329
  expect(select).not.toHaveBeenCalled();
330
+ expect(selfUpdate).toHaveBeenCalledOnce();
331
+ expect(selfUpdate).toHaveBeenCalledWith(
332
+ expect.objectContaining({
333
+ cwd: process.cwd(),
334
+ pkgRoot,
335
+ currentVersion: version,
336
+ pm: "npm",
337
+ argv: ["upgrade"],
338
+ }),
339
+ );
340
+ });
341
+
342
+ it("upgrade stops after a self-update re-exec and keeps its exit code", async () => {
343
+ process.argv = ["node", "grunt", "upgrade"];
344
+ selfUpdate.mockResolvedValue({ reexeced: true, status: 2 });
345
+ await start();
346
+ expect(init).not.toHaveBeenCalled();
347
+ expect(chunks.join("")).toBe("");
348
+ expect(process.exitCode).toBe(2);
349
+ });
350
+
351
+ it("upgrade --no-self-update skips the self-update", async () => {
352
+ process.argv = ["node", "grunt", "upgrade", "--no-self-update"];
353
+ await start();
354
+ expect(selfUpdate).not.toHaveBeenCalled();
355
+ expect(init).toHaveBeenCalledOnce();
356
+ });
357
+
358
+ it("other commands never self-update", async () => {
359
+ process.argv = ["node", "grunt", "doctor"];
360
+ await start();
361
+ expect(selfUpdate).not.toHaveBeenCalled();
325
362
  });
326
363
 
327
364
  it("map prints the folder map without a package manager", async () => {
@@ -598,6 +635,17 @@ describe("parseArgv --host", () => {
598
635
  });
599
636
  });
600
637
 
638
+ describe("parseArgv --no-self-update", () => {
639
+ it("sets the flag without a positional", () => {
640
+ expect(parseArgv(["upgrade", "--no-self-update"])).toMatchObject({
641
+ cmd: "upgrade",
642
+ args: [],
643
+ noSelfUpdate: true,
644
+ });
645
+ expect(parseArgv(["upgrade"])).toMatchObject({ noSelfUpdate: false });
646
+ });
647
+ });
648
+
601
649
  describe("parseArgv --pm", () => {
602
650
  it("pair and equals", () => {
603
651
  expect(parseArgv(["generate", "--pm", "PNPM"])).toMatchObject({
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { GRUNT_PACKAGE, samePath } from "./init.mjs"
5
+ import { addDevArgs } from "../scripts/package-manager.mjs"
6
+
7
+ /** Set on the re-execed child so it never self-updates again. */
8
+ export const REEXEC_ENV = "GRUNT_SELF_UPDATED"
9
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org"
10
+ const TIMEOUT_MS = 5000
11
+ const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
12
+
13
+ function parseVersion(v) {
14
+ const m = VERSION_RE.exec(String(v).trim())
15
+ if (!m) return null
16
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null }
17
+ }
18
+
19
+ /** -1 | 0 | 1. A prerelease sorts below its release. */
20
+ export function compareVersions(a, b) {
21
+ const pa = parseVersion(a)
22
+ const pb = parseVersion(b)
23
+ for (let i = 0; i < 3; i++) {
24
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1
25
+ }
26
+ if (pa.pre === pb.pre) return 0
27
+ if (pa.pre == null) return 1
28
+ if (pb.pre == null) return -1
29
+ return pa.pre < pb.pre ? -1 : 1
30
+ }
31
+
32
+ /** Latest dist-tag version, or null when the registry is unreachable or answers junk. */
33
+ export async function fetchLatestVersion({ fetch = globalThis.fetch, env = process.env } = {}) {
34
+ const base = String(env.npm_config_registry || DEFAULT_REGISTRY).replace(/\/+$/, "")
35
+ const url = `${base}/${GRUNT_PACKAGE.replace("/", "%2f")}/latest`
36
+ try {
37
+ const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) })
38
+ if (!res.ok) return null
39
+ const { version } = await res.json()
40
+ return parseVersion(version) ? version : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ function warn(m) {
47
+ process.stderr.write(`${m}\n`)
48
+ }
49
+
50
+ /**
51
+ * Install the latest grunt as a devDependency, then re-run the same command with the
52
+ * installed bin so the merge uses the new package. Returns `{ reexeced, status? }`.
53
+ */
54
+ export async function selfUpdate({
55
+ cwd,
56
+ pkgRoot,
57
+ currentVersion,
58
+ pm,
59
+ argv,
60
+ env = process.env,
61
+ fetch = globalThis.fetch,
62
+ run = spawnSync,
63
+ log = warn,
64
+ }) {
65
+ if (env[REEXEC_ENV]) return { reexeced: false }
66
+ if (samePath(cwd, pkgRoot)) return { reexeced: false }
67
+
68
+ const latest = await fetchLatestVersion({ fetch, env })
69
+ if (!latest) {
70
+ log(`grunt: could not check the registry for updates; upgrading with ${currentVersion}`)
71
+ return { reexeced: false }
72
+ }
73
+ if (compareVersions(currentVersion, latest) >= 0) return { reexeced: false }
74
+
75
+ log(`grunt: updating ${currentVersion} -> ${latest}`)
76
+ const install = run(pm, addDevArgs(pm, `${GRUNT_PACKAGE}@${latest}`, { cwd }), {
77
+ cwd,
78
+ stdio: "inherit",
79
+ })
80
+ if (install.error || install.status !== 0) {
81
+ throw new Error(`grunt: ${pm} install of ${GRUNT_PACKAGE}@${latest} failed`)
82
+ }
83
+
84
+ const bin = path.join(cwd, "node_modules", GRUNT_PACKAGE, "bin", "grunt.js")
85
+ if (!fs.existsSync(bin)) {
86
+ log(`grunt: ${bin} not found after install; upgrading with ${currentVersion}`)
87
+ return { reexeced: false }
88
+ }
89
+ const child = run(process.execPath, [bin, ...argv], {
90
+ cwd,
91
+ stdio: "inherit",
92
+ env: { ...env, [REEXEC_ENV]: "1" },
93
+ })
94
+ return { reexeced: true, status: child.status ?? 1 }
95
+ }
@@ -0,0 +1,196 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ REEXEC_ENV,
7
+ compareVersions,
8
+ fetchLatestVersion,
9
+ selfUpdate,
10
+ } from "./self-update.mjs";
11
+
12
+ const tmpDirs: string[] = [];
13
+ afterEach(() => {
14
+ for (const d of tmpDirs.splice(0)) {
15
+ fs.rmSync(d, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ function tmp(prefix: string) {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
21
+ tmpDirs.push(dir);
22
+ return dir;
23
+ }
24
+
25
+ function okFetch(body: unknown, status = 200) {
26
+ return vi.fn(async () => ({
27
+ ok: status >= 200 && status < 300,
28
+ status,
29
+ json: async () => body,
30
+ }));
31
+ }
32
+
33
+ function installedBin(cwd: string) {
34
+ const bin = path.join(cwd, "node_modules", "@lovrozagar", "grunt", "bin", "grunt.js");
35
+ fs.mkdirSync(path.dirname(bin), { recursive: true });
36
+ fs.writeFileSync(bin, "");
37
+ return bin;
38
+ }
39
+
40
+ describe("compareVersions", () => {
41
+ it("orders major, minor, patch numerically", () => {
42
+ expect(compareVersions("0.8.3", "0.8.4")).toBe(-1);
43
+ expect(compareVersions("0.9.0", "0.8.10")).toBe(1);
44
+ expect(compareVersions("1.0.0", "0.99.99")).toBe(1);
45
+ expect(compareVersions("0.8.10", "0.8.9")).toBe(1);
46
+ expect(compareVersions("0.8.3", "0.8.3")).toBe(0);
47
+ });
48
+
49
+ it("sorts a prerelease below its release", () => {
50
+ expect(compareVersions("1.0.0-beta.1", "1.0.0")).toBe(-1);
51
+ expect(compareVersions("1.0.0", "1.0.0-beta.1")).toBe(1);
52
+ expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1);
53
+ expect(compareVersions("1.0.0-beta.1", "1.0.0-beta.2")).toBe(-1);
54
+ });
55
+ });
56
+
57
+ describe("fetchLatestVersion", () => {
58
+ it("reads the latest dist-tag from the default registry", async () => {
59
+ const fetch = okFetch({ version: "0.9.0" });
60
+ expect(await fetchLatestVersion({ fetch, env: {} })).toBe("0.9.0");
61
+ expect(fetch.mock.calls[0][0]).toBe(
62
+ "https://registry.npmjs.org/@lovrozagar%2fgrunt/latest",
63
+ );
64
+ });
65
+
66
+ it("honors npm_config_registry", async () => {
67
+ const fetch = okFetch({ version: "0.9.0" });
68
+ await fetchLatestVersion({ fetch, env: { npm_config_registry: "https://r.example/npm/" } });
69
+ expect(fetch.mock.calls[0][0]).toBe("https://r.example/npm/@lovrozagar%2fgrunt/latest");
70
+ });
71
+
72
+ it("returns null on non-200, bad JSON, bad version, or a thrown fetch", async () => {
73
+ expect(await fetchLatestVersion({ fetch: okFetch({}, 404), env: {} })).toBeNull();
74
+ const badJson = vi.fn(async () => ({
75
+ ok: true,
76
+ status: 200,
77
+ json: async () => {
78
+ throw new SyntaxError("bad");
79
+ },
80
+ }));
81
+ expect(await fetchLatestVersion({ fetch: badJson, env: {} })).toBeNull();
82
+ expect(await fetchLatestVersion({ fetch: okFetch({ version: "latest" }), env: {} })).toBeNull();
83
+ const aborted = vi.fn(async () => {
84
+ throw new DOMException("timeout", "TimeoutError");
85
+ });
86
+ expect(await fetchLatestVersion({ fetch: aborted, env: {} })).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe("selfUpdate", () => {
91
+ function setup({
92
+ latest = "0.9.0" as string | null,
93
+ current = "0.8.3",
94
+ env = {} as Record<string, string>,
95
+ withBin = true,
96
+ runStatus = 0 as number | null,
97
+ } = {}) {
98
+ const cwd = tmp("grunt-self-update-");
99
+ const pkgRoot = tmp("grunt-pkg-");
100
+ const fetch = latest == null ? okFetch({}, 500) : okFetch({ version: latest });
101
+ const run = vi.fn((_cmd: string, args: string[]) => {
102
+ if (args[0] === "install" || args[0] === "add") {
103
+ if (withBin) installedBin(cwd);
104
+ return { status: 0 };
105
+ }
106
+ return { status: runStatus };
107
+ });
108
+ const log = vi.fn();
109
+ const opts = {
110
+ cwd,
111
+ pkgRoot,
112
+ currentVersion: current,
113
+ pm: "bun",
114
+ argv: ["upgrade", "--skip-globals"],
115
+ env,
116
+ fetch,
117
+ run,
118
+ log,
119
+ };
120
+ return { opts, fetch, run, log, cwd };
121
+ }
122
+
123
+ it("skips when already re-execed", async () => {
124
+ const { opts, fetch, run } = setup({ env: { [REEXEC_ENV]: "1" } });
125
+ expect(await selfUpdate(opts)).toEqual({ reexeced: false });
126
+ expect(fetch).not.toHaveBeenCalled();
127
+ expect(run).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("skips inside the grunt package itself", async () => {
131
+ const { opts, fetch } = setup();
132
+ expect(await selfUpdate({ ...opts, pkgRoot: opts.cwd })).toEqual({ reexeced: false });
133
+ expect(fetch).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("skips when current is latest or newer", async () => {
137
+ for (const current of ["0.9.0", "0.10.0"]) {
138
+ const { opts, run } = setup({ current });
139
+ expect(await selfUpdate(opts)).toEqual({ reexeced: false });
140
+ expect(run).not.toHaveBeenCalled();
141
+ }
142
+ });
143
+
144
+ it("warns and continues when the registry check fails", async () => {
145
+ const { opts, run, log } = setup({ latest: null });
146
+ expect(await selfUpdate(opts)).toEqual({ reexeced: false });
147
+ expect(run).not.toHaveBeenCalled();
148
+ expect(log.mock.calls[0][0]).toMatch(/could not check/);
149
+ });
150
+
151
+ it("installs latest then re-execs the installed bin", async () => {
152
+ const { opts, run, cwd } = setup({ runStatus: 3 });
153
+ expect(await selfUpdate(opts)).toEqual({ reexeced: true, status: 3 });
154
+ expect(run.mock.calls[0][0]).toBe("bun");
155
+ expect(run.mock.calls[0][1]).toEqual(["add", "-D", "@lovrozagar/grunt@0.9.0"]);
156
+ expect(run.mock.calls[0][2]).toMatchObject({ cwd, stdio: "inherit" });
157
+ const [cmd, args, o] = run.mock.calls[1];
158
+ expect(cmd).toBe(process.execPath);
159
+ expect(fs.realpathSync(args[0])).toBe(
160
+ fs.realpathSync(path.join(cwd, "node_modules", "@lovrozagar", "grunt", "bin", "grunt.js")),
161
+ );
162
+ expect(args.slice(1)).toEqual(["upgrade", "--skip-globals"]);
163
+ expect(o).toMatchObject({ cwd, stdio: "inherit" });
164
+ expect(o.env[REEXEC_ENV]).toBe("1");
165
+ });
166
+
167
+ it("reports exit 1 when the re-execed child dies without a status", async () => {
168
+ const { opts } = setup({ runStatus: null });
169
+ expect(await selfUpdate(opts)).toEqual({ reexeced: true, status: 1 });
170
+ });
171
+
172
+ it("logs to stderr by default", async () => {
173
+ const { opts } = setup({ latest: null });
174
+ const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
175
+ try {
176
+ const { log: _log, ...rest } = opts;
177
+ await selfUpdate(rest);
178
+ expect(String(write.mock.calls[0][0])).toMatch(/could not check.*\n$/);
179
+ } finally {
180
+ write.mockRestore();
181
+ }
182
+ });
183
+
184
+ it("throws when the install fails", async () => {
185
+ const { opts } = setup();
186
+ const run = vi.fn(() => ({ status: 1 }));
187
+ await expect(selfUpdate({ ...opts, run })).rejects.toThrow(/install/);
188
+ });
189
+
190
+ it("warns and continues when the installed bin is missing", async () => {
191
+ const { opts, run, log } = setup({ withBin: false });
192
+ expect(await selfUpdate(opts)).toEqual({ reexeced: false });
193
+ expect(run).toHaveBeenCalledOnce();
194
+ expect(log.mock.calls.at(-1)?.[0]).toMatch(/not found/);
195
+ });
196
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
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",
@@ -138,6 +138,15 @@ export function installArgs(_pm) {
138
138
  return ["install"];
139
139
  }
140
140
 
141
+ /** Add `spec` as a devDependency. pnpm needs `-w` at a workspace root. */
142
+ export function addDevArgs(pm, spec, { cwd = "" } = {}) {
143
+ if (pm === "npm") return ["install", "-D", spec];
144
+ if (pm === "pnpm" && isFile(path.join(cwd, "pnpm-workspace.yaml"))) {
145
+ return ["add", "-D", "-w", spec];
146
+ }
147
+ return ["add", "-D", spec];
148
+ }
149
+
141
150
  export function runScriptArgs(pm, script, extra = []) {
142
151
  if (!extra.length) return ["run", script];
143
152
  if (pm === "npm") return ["run", script, "--", ...extra];