bigpowers 2.88.9 → 2.88.10
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/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.pi/package.json +1 -1
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/scripts/lib/__pycache__/link_utils.cpython-314.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-314.pyc +0 -0
- package/scripts/lib/install-helpers.js +59 -4
- package/scripts/lib/srp-engine.py +16 -1
- package/scripts/test-install-helpers.js +50 -8
- package/tests/test_srp_engine.py +63 -0
- package/allure-results/categories.json +0 -23
- package/allure-results/executor.json +0 -6
- package/allure-results/junit-results.xml +0 -2743
- package/scripts/lib/__pycache__/link_utils.cpython-312.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-312.pyc +0 -0
package/.pi/package.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [2.88.10](https://github.com/danielvm-git/bigpowers/compare/v2.88.9...v2.88.10) (2026-10-25)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **install:** support Windows without symlink privilege ([#135](https://github.com/danielvm-git/bigpowers/issues/135)) ([c47e3c5](https://github.com/danielvm-git/bigpowers/commit/c47e3c5465679046fe980be0c5593a489efee793)), closes [#134](https://github.com/danielvm-git/bigpowers/issues/134)
|
|
7
|
+
* **srp:** resolve bash via PATH so adapters run on Windows ([#137](https://github.com/danielvm-git/bigpowers/issues/137)) ([c89f4f5](https://github.com/danielvm-git/bigpowers/commit/c89f4f563ed981a312f84cc6ac38e82a9826cd08))
|
|
8
|
+
|
|
1
9
|
## [2.88.9](https://github.com/danielvm-git/bigpowers/compare/v2.88.8...v2.88.9) (2026-09-21)
|
|
2
10
|
|
|
3
11
|
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// story: e72s02
|
|
15
15
|
// story: e68s02
|
|
16
16
|
// story: e65s02
|
|
17
|
+
// story: #134
|
|
17
18
|
// install-helpers.js — symlink helpers for bigpowers setup
|
|
18
19
|
|
|
19
20
|
const fs = require('fs');
|
|
@@ -23,6 +24,50 @@ const path = require('path');
|
|
|
23
24
|
// directory, which is how "managed" is detected (see assertReplaceable).
|
|
24
25
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
25
26
|
|
|
27
|
+
// Windows denies symlink creation unless the shell is elevated or Developer
|
|
28
|
+
// Mode is on (EPERM). Directory *junctions* need no privilege, and Node reports
|
|
29
|
+
// a junction through lstat/readlink exactly like a symlink — so install,
|
|
30
|
+
// detectExistingInstall, and uninstall keep working. A file cannot be junctioned
|
|
31
|
+
// (the result is a broken reparse point), so a file link on Windows becomes a
|
|
32
|
+
// copy, tracked here so a reinstall replaces it and uninstall removes it.
|
|
33
|
+
const copiesPath = () => path.join(require('os').homedir(), '.bigpowers', 'managed-copies.json');
|
|
34
|
+
|
|
35
|
+
function managedCopies() {
|
|
36
|
+
try {
|
|
37
|
+
return new Set(JSON.parse(fs.readFileSync(copiesPath(), 'utf8')));
|
|
38
|
+
} catch {
|
|
39
|
+
return new Set();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function saveCopies(copies) {
|
|
44
|
+
fs.mkdirSync(path.dirname(copiesPath()), { recursive: true });
|
|
45
|
+
fs.writeFileSync(copiesPath(), JSON.stringify([...copies], null, 2));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Symlink on POSIX; on Windows junction directories and copy (tracked) files.
|
|
49
|
+
function createLink(src, dst) {
|
|
50
|
+
if (process.platform !== 'win32') {
|
|
51
|
+
fs.symlinkSync(src, dst);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!fs.existsSync(src)) return; // source absent from this build (e.g. AGENTS.md)
|
|
55
|
+
if (fs.statSync(src).isDirectory()) {
|
|
56
|
+
fs.symlinkSync(src, dst, 'junction');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
fs.symlinkSync(src, dst);
|
|
61
|
+
const copies = managedCopies();
|
|
62
|
+
if (copies.delete(dst)) saveCopies(copies); // was a copy; now a real link
|
|
63
|
+
} catch {
|
|
64
|
+
fs.copyFileSync(src, dst);
|
|
65
|
+
const copies = managedCopies();
|
|
66
|
+
copies.add(dst);
|
|
67
|
+
saveCopies(copies);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
26
71
|
// Refuse to replace a destination that is not already a bigpowers-managed
|
|
27
72
|
// symlink. Prevents setup from silently destroying a user's own file or
|
|
28
73
|
// directory (e.g. an existing ~/.claude/hooks/rtk-rewrite.sh installed by
|
|
@@ -42,6 +87,9 @@ function assertReplaceable(dst) {
|
|
|
42
87
|
return; // managed by bigpowers — safe to re-link
|
|
43
88
|
}
|
|
44
89
|
}
|
|
90
|
+
if (st.isFile() && managedCopies().has(dst)) {
|
|
91
|
+
return; // file copy placed by the Windows fallback — safe to re-link
|
|
92
|
+
}
|
|
45
93
|
throw new Error(
|
|
46
94
|
`Refusing to replace ${dst}: it exists and is not a bigpowers-managed symlink. ` +
|
|
47
95
|
`Back it up or remove it manually, then re-run setup.`
|
|
@@ -58,7 +106,7 @@ function linkSkills(skillsDir, targetDir) {
|
|
|
58
106
|
|
|
59
107
|
assertReplaceable(dst);
|
|
60
108
|
fs.rmSync(dst, { force: true });
|
|
61
|
-
|
|
109
|
+
createLink(src, dst);
|
|
62
110
|
}
|
|
63
111
|
}
|
|
64
112
|
|
|
@@ -70,13 +118,13 @@ function linkDir(src, dst) {
|
|
|
70
118
|
}
|
|
71
119
|
assertReplaceable(dst);
|
|
72
120
|
fs.rmSync(dst, { force: true });
|
|
73
|
-
|
|
121
|
+
createLink(src, dst);
|
|
74
122
|
}
|
|
75
123
|
|
|
76
124
|
function linkFile(src, dst) {
|
|
77
125
|
assertReplaceable(dst);
|
|
78
126
|
fs.rmSync(dst, { force: true });
|
|
79
|
-
|
|
127
|
+
createLink(src, dst);
|
|
80
128
|
}
|
|
81
129
|
|
|
82
130
|
function linkRenderedSkills(renderedDir, targetDir) {
|
|
@@ -106,7 +154,7 @@ function linkHook(src, dst) {
|
|
|
106
154
|
}
|
|
107
155
|
assertReplaceable(dst);
|
|
108
156
|
fs.rmSync(dst, { force: true });
|
|
109
|
-
|
|
157
|
+
createLink(src, dst);
|
|
110
158
|
try { fs.chmodSync(src, 0o755); } catch {}
|
|
111
159
|
}
|
|
112
160
|
|
|
@@ -667,6 +715,13 @@ function removeSymlink(p) {
|
|
|
667
715
|
fs.unlinkSync(p);
|
|
668
716
|
return true;
|
|
669
717
|
}
|
|
718
|
+
const copies = managedCopies();
|
|
719
|
+
if (stat.isFile() && copies.has(p)) {
|
|
720
|
+
fs.unlinkSync(p);
|
|
721
|
+
copies.delete(p);
|
|
722
|
+
saveCopies(copies);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
670
725
|
} catch {}
|
|
671
726
|
return false;
|
|
672
727
|
}
|
|
@@ -5,6 +5,7 @@ import os
|
|
|
5
5
|
import sys
|
|
6
6
|
import json
|
|
7
7
|
import glob
|
|
8
|
+
import shutil
|
|
8
9
|
import subprocess
|
|
9
10
|
|
|
10
11
|
# Ensure scripts/lib is on the path so sibling modules (link_utils, and the
|
|
@@ -191,6 +192,20 @@ def render_okf_concept(skill_data, okf_wiki_skills):
|
|
|
191
192
|
with open(okf_file, 'w', encoding='utf-8') as f:
|
|
192
193
|
f.write('\n'.join(lines) + '\n')
|
|
193
194
|
|
|
195
|
+
def resolve_bash():
|
|
196
|
+
"""Return an explicit path to a usable bash interpreter.
|
|
197
|
+
|
|
198
|
+
On Windows, subprocess(['bash', ...]) goes through the Win32
|
|
199
|
+
CreateProcess search order, which checks C:\\Windows\\System32 before
|
|
200
|
+
PATH. That finds the WSL launcher stub (System32\\bash.exe) instead of
|
|
201
|
+
Git Bash and aborts with 'Windows Subsystem for Linux has no installed
|
|
202
|
+
distributions' on hosts without a WSL distro. shutil.which() honors PATH
|
|
203
|
+
order, so ask it for an explicit path first; fall back to the bare name
|
|
204
|
+
for minimal non-Windows environments where nothing is on PATH.
|
|
205
|
+
"""
|
|
206
|
+
return os.environ.get("BIGPOWERS_BASH") or shutil.which("bash") or "bash"
|
|
207
|
+
|
|
208
|
+
|
|
194
209
|
def dispatch_to_adapter(skill_data, target, repo_root):
|
|
195
210
|
adapter_path = os.path.join(repo_root, "scripts", "adapters", f"{target}.sh")
|
|
196
211
|
if not os.path.isfile(adapter_path):
|
|
@@ -199,7 +214,7 @@ def dispatch_to_adapter(skill_data, target, repo_root):
|
|
|
199
214
|
|
|
200
215
|
# Pipe JSON to the adapter
|
|
201
216
|
proc = subprocess.Popen(
|
|
202
|
-
[
|
|
217
|
+
[resolve_bash(), adapter_path],
|
|
203
218
|
stdin=subprocess.PIPE,
|
|
204
219
|
stdout=subprocess.PIPE,
|
|
205
220
|
stderr=subprocess.PIPE,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// story: e60s01
|
|
3
|
+
// story: #134
|
|
3
4
|
// Regression selftest for scripts/lib/install-helpers.js (Wave 0 closeout).
|
|
4
5
|
'use strict';
|
|
5
6
|
|
|
@@ -12,9 +13,25 @@ const { mkdtempSync, rmSync } = fs;
|
|
|
12
13
|
const ROOT = path.resolve(__dirname, '..');
|
|
13
14
|
const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'bp-install-helpers-'));
|
|
14
15
|
process.env.HOME = tmpHome;
|
|
16
|
+
process.env.USERPROFILE = tmpHome; // os.homedir() prefers USERPROFILE on Windows
|
|
15
17
|
|
|
16
18
|
const { installGlobal, linkHook } = require('../scripts/lib/install-helpers.js');
|
|
17
19
|
|
|
20
|
+
const isWindows = process.platform === 'win32';
|
|
21
|
+
|
|
22
|
+
// Directories become junctions on Windows, which lstat reports as symlinks.
|
|
23
|
+
// Files cannot be symlinked without Developer Mode, so createLink copies them;
|
|
24
|
+
// accept either shape and prove a copy matches the source byte-for-byte.
|
|
25
|
+
function assertLinkedFile(dst, src, msg) {
|
|
26
|
+
const st = fs.lstatSync(dst);
|
|
27
|
+
if (st.isSymbolicLink()) {
|
|
28
|
+
assert.strictEqual(fs.readlinkSync(dst), src, msg);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
assert.ok(isWindows && st.isFile(), `${msg} (expected a symlink, or a Windows copy)`);
|
|
32
|
+
assert.strictEqual(fs.readFileSync(dst, 'utf8'), fs.readFileSync(src, 'utf8'), msg);
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
try {
|
|
19
36
|
const hookSrc = path.join(ROOT, 'skills', 'guard-git', 'scripts', 'block-dangerous-git.sh');
|
|
20
37
|
assert.ok(fs.existsSync(hookSrc), `Claude guard-git hook source must exist: ${hookSrc}`);
|
|
@@ -40,8 +57,7 @@ try {
|
|
|
40
57
|
installGlobal({ id: 'claude', name: 'Claude Code' }, ROOT);
|
|
41
58
|
|
|
42
59
|
const linked = path.join(tmpHome, '.claude', 'hooks', 'block-dangerous-git.sh');
|
|
43
|
-
|
|
44
|
-
assert.strictEqual(fs.readlinkSync(linked), hookSrc, 'symlink must point at skills/guard-git path');
|
|
60
|
+
assertLinkedFile(linked, hookSrc, 'block-dangerous-git.sh must link to skills/guard-git');
|
|
45
61
|
|
|
46
62
|
const libLinked = path.join(tmpHome, '.claude', 'hooks', 'lib');
|
|
47
63
|
const libSrc = path.join(ROOT, 'skills', 'guard-git', 'scripts', 'lib');
|
|
@@ -53,8 +69,29 @@ try {
|
|
|
53
69
|
);
|
|
54
70
|
|
|
55
71
|
const rtkLinked = path.join(tmpHome, '.claude', 'hooks', 'rtk-rewrite.sh');
|
|
56
|
-
|
|
57
|
-
|
|
72
|
+
assertLinkedFile(rtkLinked, rtkSrc, 'rtk-rewrite.sh must link to scripts/hooks source');
|
|
73
|
+
|
|
74
|
+
if (isWindows) {
|
|
75
|
+
// Windows EPERM regression: without Developer Mode the hooks above are
|
|
76
|
+
// copies. A reinstall must not trip the "Refusing to replace" guard, and
|
|
77
|
+
// uninstall must remove the copies and clear them from the manifest.
|
|
78
|
+
const manifest = path.join(tmpHome, '.bigpowers', 'managed-copies.json');
|
|
79
|
+
const tracked = () => (fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, 'utf8')) : []);
|
|
80
|
+
for (const p of [linked, rtkLinked]) {
|
|
81
|
+
if (!fs.lstatSync(p).isSymbolicLink()) {
|
|
82
|
+
assert.ok(tracked().includes(p), `Windows copy must be tracked in the manifest: ${p}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
installGlobal({ id: 'claude', name: 'Claude Code' }, ROOT); // idempotent reinstall
|
|
86
|
+
uninstallTool('claude', ROOT);
|
|
87
|
+
assert.ok(!fs.existsSync(linked), 'uninstall must remove the block-dangerous-git hook');
|
|
88
|
+
assert.ok(!fs.existsSync(rtkLinked), 'uninstall must remove the rtk-rewrite hook');
|
|
89
|
+
assert.deepStrictEqual(
|
|
90
|
+
tracked().filter((p) => p === linked || p === rtkLinked),
|
|
91
|
+
[],
|
|
92
|
+
'uninstall must clear removed copies from the manifest'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
58
95
|
|
|
59
96
|
installGlobal({ id: 'pi', name: 'pi' }, ROOT);
|
|
60
97
|
const piSkill = path.join(tmpHome, '.pi', 'agent', 'skills');
|
|
@@ -173,9 +210,10 @@ try {
|
|
|
173
210
|
|
|
174
211
|
installLocal({ id: 'codex', name: 'Codex' }, ROOT);
|
|
175
212
|
const localCodexAgents = path.join(tmpCwd, '.codex', 'AGENTS.md');
|
|
176
|
-
|
|
213
|
+
assertLinkedFile(localCodexAgents, path.join(ROOT, 'templates', 'codex', 'AGENTS.md'),
|
|
214
|
+
'local codex install must link AGENTS.md');
|
|
177
215
|
uninstallTool('codex', ROOT);
|
|
178
|
-
assert.ok(!fs.existsSync(localCodexAgents), 'uninstallTool(codex) must remove LOCAL AGENTS.md
|
|
216
|
+
assert.ok(!fs.existsSync(localCodexAgents), 'uninstallTool(codex) must remove LOCAL AGENTS.md');
|
|
179
217
|
} finally {
|
|
180
218
|
process.chdir(savedCwd);
|
|
181
219
|
rmSync(tmpCwd, { recursive: true, force: true });
|
|
@@ -353,8 +391,12 @@ try {
|
|
|
353
391
|
// package root (e.g. <ROOT>-evil) as managed — needs a path-separator boundary.
|
|
354
392
|
{
|
|
355
393
|
const dst = path.join(tmpHome, 'prefix-boundary-hook.sh');
|
|
356
|
-
|
|
357
|
-
|
|
394
|
+
if (isWindows) {
|
|
395
|
+
fs.writeFileSync(dst, 'foreign\n'); // Windows cannot make this link unprivileged
|
|
396
|
+
} else {
|
|
397
|
+
fs.symlinkSync(path.join(`${ROOT}-evil`, 'x.sh'), dst); // dangling foreign link
|
|
398
|
+
}
|
|
399
|
+
assert.throws(
|
|
358
400
|
() => linkHook(rtkSrc, dst),
|
|
359
401
|
/Refusing to replace/,
|
|
360
402
|
'managed-symlink check must require a path-separator boundary (prefix sibling is NOT managed)'
|
package/tests/test_srp_engine.py
CHANGED
|
@@ -6,7 +6,9 @@ Run: python3 -m pytest tests/test_srp_engine.py -v
|
|
|
6
6
|
"""
|
|
7
7
|
import os
|
|
8
8
|
import sys
|
|
9
|
+
import tempfile
|
|
9
10
|
import unittest
|
|
11
|
+
from unittest import mock
|
|
10
12
|
|
|
11
13
|
# Make scripts/lib importable without installation.
|
|
12
14
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -144,5 +146,66 @@ class TestRewriteLinksForPi(unittest.TestCase):
|
|
|
144
146
|
self.assertNotIn("](REFERENCE.md)", result) # real link rewritten
|
|
145
147
|
|
|
146
148
|
|
|
149
|
+
class TestResolveBash(unittest.TestCase):
|
|
150
|
+
"""Regression: dispatch must not make subprocess resolve the bare name
|
|
151
|
+
'bash'. On Windows the Win32 CreateProcess search order finds the WSL
|
|
152
|
+
stub in System32 before Git Bash on PATH, so setup aborted with
|
|
153
|
+
'Windows Subsystem for Linux has no installed distributions'."""
|
|
154
|
+
|
|
155
|
+
def test_env_override_wins(self):
|
|
156
|
+
with mock.patch.dict(os.environ, {"BIGPOWERS_BASH": "/custom/bash"}):
|
|
157
|
+
self.assertEqual(_srp.resolve_bash(), "/custom/bash")
|
|
158
|
+
|
|
159
|
+
def test_uses_path_resolved_bash(self):
|
|
160
|
+
env = {k: v for k, v in os.environ.items() if k != "BIGPOWERS_BASH"}
|
|
161
|
+
with mock.patch.dict(os.environ, env, clear=True):
|
|
162
|
+
with mock.patch.object(_srp.shutil, "which", return_value="/usr/bin/bash"):
|
|
163
|
+
self.assertEqual(_srp.resolve_bash(), "/usr/bin/bash")
|
|
164
|
+
|
|
165
|
+
def test_falls_back_to_bare_bash(self):
|
|
166
|
+
env = {k: v for k, v in os.environ.items() if k != "BIGPOWERS_BASH"}
|
|
167
|
+
with mock.patch.dict(os.environ, env, clear=True):
|
|
168
|
+
with mock.patch.object(_srp.shutil, "which", return_value=None):
|
|
169
|
+
self.assertEqual(_srp.resolve_bash(), "bash")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestDispatchUsesResolvedBash(unittest.TestCase):
|
|
173
|
+
"""The adapter subprocess must receive the PATH-resolved bash path, not
|
|
174
|
+
the bare name that Windows resolves to the WSL stub."""
|
|
175
|
+
|
|
176
|
+
def setUp(self):
|
|
177
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
178
|
+
self.repo_root = self._tmp.name
|
|
179
|
+
adapter_dir = os.path.join(self.repo_root, "scripts", "adapters")
|
|
180
|
+
os.makedirs(adapter_dir)
|
|
181
|
+
self.adapter = os.path.join(adapter_dir, "fake.sh")
|
|
182
|
+
with open(self.adapter, "w", encoding="utf-8") as f:
|
|
183
|
+
f.write("#!/usr/bin/env bash\n")
|
|
184
|
+
|
|
185
|
+
def tearDown(self):
|
|
186
|
+
self._tmp.cleanup()
|
|
187
|
+
|
|
188
|
+
def test_dispatch_passes_resolved_bash_to_popen(self):
|
|
189
|
+
captured = {}
|
|
190
|
+
|
|
191
|
+
class _FakeProc:
|
|
192
|
+
returncode = 0
|
|
193
|
+
|
|
194
|
+
def communicate(self, input=None):
|
|
195
|
+
return ("", "")
|
|
196
|
+
|
|
197
|
+
def _fake_popen(cmd, **kwargs):
|
|
198
|
+
captured["cmd"] = cmd
|
|
199
|
+
return _FakeProc()
|
|
200
|
+
|
|
201
|
+
sentinel = os.path.join(self.repo_root, "git-bash")
|
|
202
|
+
with mock.patch.object(_srp, "resolve_bash", return_value=sentinel):
|
|
203
|
+
with mock.patch.object(_srp.subprocess, "Popen", side_effect=_fake_popen):
|
|
204
|
+
_srp.dispatch_to_adapter({"name": "x"}, "fake", self.repo_root)
|
|
205
|
+
|
|
206
|
+
self.assertEqual(captured["cmd"][0], sentinel)
|
|
207
|
+
self.assertEqual(captured["cmd"][1], self.adapter)
|
|
208
|
+
|
|
209
|
+
|
|
147
210
|
if __name__ == "__main__":
|
|
148
211
|
unittest.main()
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
[
|
|
2
|
-
{
|
|
3
|
-
"name": "P0 Risk",
|
|
4
|
-
"matchedStatuses": [
|
|
5
|
-
"failed"
|
|
6
|
-
],
|
|
7
|
-
"messageRegex": ".*risk.*P0.*"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"name": "Security Review",
|
|
11
|
-
"matchedStatuses": [
|
|
12
|
-
"failed"
|
|
13
|
-
],
|
|
14
|
-
"messageRegex": ".*security.*(?:medium|high).*"
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"name": "Open Bugs",
|
|
18
|
-
"matchedStatuses": [
|
|
19
|
-
"failed"
|
|
20
|
-
],
|
|
21
|
-
"messageRegex": ".*Bug.*"
|
|
22
|
-
}
|
|
23
|
-
]
|