@lovrozagar/grunt 0.8.3 → 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.
- package/.grok/hooks/rtk-grok.js +2 -4
- package/.rulesync/rules/overview.md +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +8 -5
- package/bin/grunt.js +2 -1
- package/cli/grunt.mjs +33 -3
- package/cli/grunt.test.ts +50 -2
- package/cli/init.mjs +23 -16
- package/cli/init.test.ts +80 -0
- package/cli/self-update.mjs +97 -0
- package/cli/self-update.test.ts +208 -0
- package/cli/spawn.mjs +20 -0
- package/cli/spawn.test.ts +34 -0
- package/package.json +8 -3
- package/scripts/emit-mcp-policy.mjs +1 -6
- package/scripts/folder-map.mjs +1 -1
- package/scripts/jsonc.mjs +7 -0
- package/scripts/package-manager.mjs +9 -0
package/.grok/hooks/rtk-grok.js
CHANGED
|
@@ -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
|
-
|
|
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 (;;) {
|
|
@@ -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
|
|
120
|
-
pnpm
|
|
121
|
-
yarn
|
|
122
|
-
|
|
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/bin/grunt.js
CHANGED
package/cli/grunt.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { execFileSync } from "
|
|
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"
|
|
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
|
|
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 {
|
|
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", () => ({
|
|
@@ -29,7 +30,8 @@ vi.mock("./init.mjs", () => ({
|
|
|
29
30
|
RESERVED_SKILLS: ["browser", "tmp", "write-plan"],
|
|
30
31
|
GRUNT_NPM_PREFIX: "grunt:",
|
|
31
32
|
}));
|
|
32
|
-
vi.mock("
|
|
33
|
+
vi.mock("./spawn.mjs", () => ({ 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
|
|
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({
|
package/cli/init.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from "./spawn.mjs"
|
|
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
|
+
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 {
|
|
93
|
+
log(`grunt: ${bin} not found after install; upgrading with ${currentVersion}`)
|
|
94
|
+
return { reexeced: false }
|
|
95
|
+
}
|
|
96
|
+
return { reexeced: true, status: child.status ?? 1 }
|
|
97
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
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("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
|
+
|
|
202
|
+
it("warns and continues when the installed bin is missing", async () => {
|
|
203
|
+
const { opts, run, log } = setup({ withBin: false });
|
|
204
|
+
expect(await selfUpdate(opts)).toEqual({ reexeced: false });
|
|
205
|
+
expect(run).toHaveBeenCalledOnce();
|
|
206
|
+
expect(log.mock.calls.at(-1)?.[0]).toMatch(/not found/);
|
|
207
|
+
});
|
|
208
|
+
});
|
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.
|
|
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;
|
package/scripts/folder-map.mjs
CHANGED
|
@@ -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 "./
|
|
18
|
+
import { stripJsonc } from "./jsonc.mjs";
|
|
19
19
|
|
|
20
20
|
export const DEFAULTS = Object.freeze({
|
|
21
21
|
budget: 5000,
|
|
@@ -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];
|