factorly 0.18.1__tar.gz → 0.20.0__tar.gz
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.
- {factorly-0.18.1 → factorly-0.20.0}/PKG-INFO +1 -1
- {factorly-0.18.1 → factorly-0.20.0}/factorly/__init__.py +1 -1
- factorly-0.20.0/factorly/cli.py +182 -0
- {factorly-0.18.1 → factorly-0.20.0}/factorly.egg-info/PKG-INFO +1 -1
- {factorly-0.18.1 → factorly-0.20.0}/factorly.egg-info/SOURCES.txt +2 -1
- {factorly-0.18.1 → factorly-0.20.0}/pyproject.toml +1 -1
- factorly-0.20.0/tests/test_cli.py +36 -0
- factorly-0.18.1/factorly/cli.py +0 -128
- {factorly-0.18.1 → factorly-0.20.0}/README.md +0 -0
- {factorly-0.18.1 → factorly-0.20.0}/factorly/__main__.py +0 -0
- {factorly-0.18.1 → factorly-0.20.0}/factorly.egg-info/dependency_links.txt +0 -0
- {factorly-0.18.1 → factorly-0.20.0}/factorly.egg-info/entry_points.txt +0 -0
- {factorly-0.18.1 → factorly-0.20.0}/factorly.egg-info/top_level.txt +0 -0
- {factorly-0.18.1 → factorly-0.20.0}/setup.cfg +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Copyright 2026 Jordan Sherer <hi@jordansherer.com>
|
|
2
|
+
# SPDX-License-Identifier: gpl
|
|
3
|
+
|
|
4
|
+
"""Factorly CLI wrapper — downloads and runs the Go binary."""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from factorly import __version__
|
|
15
|
+
|
|
16
|
+
REPO = "factorly-dev/factorly"
|
|
17
|
+
|
|
18
|
+
PLATFORM_MAP = {
|
|
19
|
+
"Darwin": "darwin",
|
|
20
|
+
"Linux": "linux",
|
|
21
|
+
"Windows": "windows",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ARCH_MAP = {
|
|
25
|
+
"x86_64": "amd64",
|
|
26
|
+
"AMD64": "amd64",
|
|
27
|
+
"aarch64": "arm64",
|
|
28
|
+
"arm64": "arm64",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_bin_dir():
|
|
33
|
+
"""Return the directory where the binary is stored."""
|
|
34
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_binary_path():
|
|
38
|
+
"""Return the full path to the factorly binary."""
|
|
39
|
+
ext = ".exe" if platform.system() == "Windows" else ""
|
|
40
|
+
return os.path.join(get_bin_dir(), f"factorly{ext}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_binary_name():
|
|
44
|
+
"""Return the platform-specific binary name for download."""
|
|
45
|
+
plat = PLATFORM_MAP.get(platform.system())
|
|
46
|
+
arch = ARCH_MAP.get(platform.machine())
|
|
47
|
+
|
|
48
|
+
if not plat:
|
|
49
|
+
print(f"Unsupported platform: {platform.system()}", file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
if not arch:
|
|
52
|
+
print(f"Unsupported architecture: {platform.machine()}", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
ext = ".exe" if platform.system() == "Windows" else ""
|
|
56
|
+
return f"factorly-{__version__}-{plat}-{arch}{ext}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_installed():
|
|
60
|
+
"""Check if the correct version is already installed."""
|
|
61
|
+
binary = get_binary_path()
|
|
62
|
+
if not os.path.exists(binary):
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
# The probe runs on every launch; keep the binary's own daily
|
|
66
|
+
# update check out of it so the probe can't block on the network.
|
|
67
|
+
env = dict(os.environ, FACTORLY_NO_UPDATE_CHECK="1")
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[binary, "version"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=5,
|
|
73
|
+
env=env,
|
|
74
|
+
)
|
|
75
|
+
return __version__ in result.stdout
|
|
76
|
+
except Exception:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def expected_checksum(checksums_text, binary_name):
|
|
81
|
+
"""Return the sha256 listed for binary_name in a checksums.txt body
|
|
82
|
+
(``<hex> <name>`` per line, as sha256sum writes it), or None."""
|
|
83
|
+
for line in checksums_text.splitlines():
|
|
84
|
+
parts = line.split()
|
|
85
|
+
if len(parts) >= 2 and parts[-1] == binary_name:
|
|
86
|
+
return parts[0].lower()
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_checksum(data, checksums_text, binary_name):
|
|
91
|
+
"""Raise ValueError if data's sha256 does not match the published one.
|
|
92
|
+
A checksums file that does not list the binary is also an error."""
|
|
93
|
+
want = expected_checksum(checksums_text, binary_name)
|
|
94
|
+
if want is None:
|
|
95
|
+
raise ValueError(f"{binary_name} is not listed in checksums.txt")
|
|
96
|
+
got = hashlib.sha256(data).hexdigest()
|
|
97
|
+
if got != want:
|
|
98
|
+
raise ValueError(f"sha256 mismatch for {binary_name}: got {got}, expected {want}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fetch(url):
|
|
102
|
+
req = urllib.request.Request(url, headers={"User-Agent": "factorly-pip-installer"})
|
|
103
|
+
with urllib.request.urlopen(req) as response:
|
|
104
|
+
return response.read()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def download_binary():
|
|
108
|
+
"""Download the factorly binary from GitHub Releases and verify it
|
|
109
|
+
against the release's checksums.txt."""
|
|
110
|
+
binary_name = get_binary_name()
|
|
111
|
+
base = f"https://github.com/{REPO}/releases/download/v{__version__}"
|
|
112
|
+
url = f"{base}/{binary_name}"
|
|
113
|
+
|
|
114
|
+
# Everything the wrapper says goes to stderr: on a first launch the
|
|
115
|
+
# command may be `factorly serve`, whose stdout is an MCP client's
|
|
116
|
+
# JSON-RPC stream.
|
|
117
|
+
log = lambda msg: print(msg, file=sys.stderr)
|
|
118
|
+
log(f"Downloading factorly {__version__} for {platform.system()}/{platform.machine()}...")
|
|
119
|
+
log(f" {url}")
|
|
120
|
+
|
|
121
|
+
bin_dir = get_bin_dir()
|
|
122
|
+
os.makedirs(bin_dir, exist_ok=True)
|
|
123
|
+
binary_path = get_binary_path()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
data = _fetch(url)
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
log(f"Download failed: HTTP {e.code}")
|
|
129
|
+
log("")
|
|
130
|
+
log("Install manually from:")
|
|
131
|
+
log(f" https://github.com/{REPO}/releases/tag/v{__version__}")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
log(f"Download failed: {e}")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
# Verify against the release's checksums.txt. A release without one
|
|
138
|
+
# cannot be verified and says so; a mismatch is a hard stop.
|
|
139
|
+
try:
|
|
140
|
+
checksums = _fetch(f"{base}/checksums.txt").decode("utf-8", "replace")
|
|
141
|
+
except urllib.error.HTTPError as e:
|
|
142
|
+
log(f"warning: checksums.txt not available for v{__version__} (HTTP {e.code}); binary not verified")
|
|
143
|
+
checksums = None
|
|
144
|
+
except Exception as e:
|
|
145
|
+
log(f"warning: could not fetch checksums.txt ({e}); binary not verified")
|
|
146
|
+
checksums = None
|
|
147
|
+
if checksums is not None:
|
|
148
|
+
try:
|
|
149
|
+
verify_checksum(data, checksums, binary_name)
|
|
150
|
+
except ValueError as e:
|
|
151
|
+
log(f"Download rejected: {e}")
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
log(" sha256 verified")
|
|
154
|
+
|
|
155
|
+
with open(binary_path, "wb") as f:
|
|
156
|
+
f.write(data)
|
|
157
|
+
|
|
158
|
+
# Make executable
|
|
159
|
+
if platform.system() != "Windows":
|
|
160
|
+
st = os.stat(binary_path)
|
|
161
|
+
os.chmod(binary_path, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
162
|
+
|
|
163
|
+
log(f"Installed factorly to {binary_path}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main():
|
|
167
|
+
"""Entry point: ensure binary exists, then run it."""
|
|
168
|
+
if not is_installed():
|
|
169
|
+
download_binary()
|
|
170
|
+
|
|
171
|
+
binary = get_binary_path()
|
|
172
|
+
if not os.path.exists(binary):
|
|
173
|
+
print("factorly binary not found after download attempt.", file=sys.stderr)
|
|
174
|
+
print("Run: pip install --force-reinstall factorly", file=sys.stderr)
|
|
175
|
+
sys.exit(127)
|
|
176
|
+
|
|
177
|
+
result = subprocess.run([binary] + sys.argv[1:])
|
|
178
|
+
sys.exit(result.returncode)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright 2026 Jordan Sherer <hi@jordansherer.com>
|
|
2
|
+
# SPDX-License-Identifier: gpl
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from factorly.cli import expected_checksum, verify_checksum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChecksumTests(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.data = b"binary bytes"
|
|
13
|
+
self.digest = hashlib.sha256(self.data).hexdigest()
|
|
14
|
+
self.text = (
|
|
15
|
+
f"{self.digest} factorly-0.1.0-linux-amd64\n"
|
|
16
|
+
"deadbeef factorly-0.1.0-darwin-arm64\n"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def test_expected_checksum_finds_binary(self):
|
|
20
|
+
self.assertEqual(expected_checksum(self.text, "factorly-0.1.0-linux-amd64"), self.digest)
|
|
21
|
+
self.assertIsNone(expected_checksum(self.text, "factorly-0.1.0-windows-amd64.exe"))
|
|
22
|
+
|
|
23
|
+
def test_verify_accepts_matching_digest(self):
|
|
24
|
+
verify_checksum(self.data, self.text, "factorly-0.1.0-linux-amd64")
|
|
25
|
+
|
|
26
|
+
def test_verify_rejects_mismatch(self):
|
|
27
|
+
with self.assertRaises(ValueError):
|
|
28
|
+
verify_checksum(b"tampered", self.text, "factorly-0.1.0-linux-amd64")
|
|
29
|
+
|
|
30
|
+
def test_verify_rejects_unlisted_binary(self):
|
|
31
|
+
with self.assertRaises(ValueError):
|
|
32
|
+
verify_checksum(self.data, self.text, "factorly-0.1.0-windows-amd64.exe")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
unittest.main()
|
factorly-0.18.1/factorly/cli.py
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
# Copyright 2026 Jordan Sherer <hi@jordansherer.com>
|
|
2
|
-
# SPDX-License-Identifier: gpl
|
|
3
|
-
|
|
4
|
-
"""Factorly CLI wrapper — downloads and runs the Go binary."""
|
|
5
|
-
|
|
6
|
-
import os
|
|
7
|
-
import platform
|
|
8
|
-
import stat
|
|
9
|
-
import subprocess
|
|
10
|
-
import sys
|
|
11
|
-
import urllib.request
|
|
12
|
-
|
|
13
|
-
from factorly import __version__
|
|
14
|
-
|
|
15
|
-
REPO = "factorly-dev/factorly"
|
|
16
|
-
|
|
17
|
-
PLATFORM_MAP = {
|
|
18
|
-
"Darwin": "darwin",
|
|
19
|
-
"Linux": "linux",
|
|
20
|
-
"Windows": "windows",
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
ARCH_MAP = {
|
|
24
|
-
"x86_64": "amd64",
|
|
25
|
-
"AMD64": "amd64",
|
|
26
|
-
"aarch64": "arm64",
|
|
27
|
-
"arm64": "arm64",
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def get_bin_dir():
|
|
32
|
-
"""Return the directory where the binary is stored."""
|
|
33
|
-
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def get_binary_path():
|
|
37
|
-
"""Return the full path to the factorly binary."""
|
|
38
|
-
ext = ".exe" if platform.system() == "Windows" else ""
|
|
39
|
-
return os.path.join(get_bin_dir(), f"factorly{ext}")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def get_binary_name():
|
|
43
|
-
"""Return the platform-specific binary name for download."""
|
|
44
|
-
plat = PLATFORM_MAP.get(platform.system())
|
|
45
|
-
arch = ARCH_MAP.get(platform.machine())
|
|
46
|
-
|
|
47
|
-
if not plat:
|
|
48
|
-
print(f"Unsupported platform: {platform.system()}", file=sys.stderr)
|
|
49
|
-
sys.exit(1)
|
|
50
|
-
if not arch:
|
|
51
|
-
print(f"Unsupported architecture: {platform.machine()}", file=sys.stderr)
|
|
52
|
-
sys.exit(1)
|
|
53
|
-
|
|
54
|
-
ext = ".exe" if platform.system() == "Windows" else ""
|
|
55
|
-
return f"factorly-{__version__}-{plat}-{arch}{ext}"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def is_installed():
|
|
59
|
-
"""Check if the correct version is already installed."""
|
|
60
|
-
binary = get_binary_path()
|
|
61
|
-
if not os.path.exists(binary):
|
|
62
|
-
return False
|
|
63
|
-
try:
|
|
64
|
-
result = subprocess.run(
|
|
65
|
-
[binary, "version"],
|
|
66
|
-
capture_output=True,
|
|
67
|
-
text=True,
|
|
68
|
-
timeout=5,
|
|
69
|
-
)
|
|
70
|
-
return __version__ in result.stdout
|
|
71
|
-
except Exception:
|
|
72
|
-
return False
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def download_binary():
|
|
76
|
-
"""Download the factorly binary from GitHub Releases."""
|
|
77
|
-
binary_name = get_binary_name()
|
|
78
|
-
url = f"https://github.com/{REPO}/releases/download/v{__version__}/{binary_name}"
|
|
79
|
-
|
|
80
|
-
print(f"Downloading factorly {__version__} for {platform.system()}/{platform.machine()}...")
|
|
81
|
-
print(f" {url}")
|
|
82
|
-
|
|
83
|
-
bin_dir = get_bin_dir()
|
|
84
|
-
os.makedirs(bin_dir, exist_ok=True)
|
|
85
|
-
binary_path = get_binary_path()
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
req = urllib.request.Request(url, headers={"User-Agent": "factorly-pip-installer"})
|
|
89
|
-
with urllib.request.urlopen(req) as response:
|
|
90
|
-
data = response.read()
|
|
91
|
-
except urllib.error.HTTPError as e:
|
|
92
|
-
print(f"Download failed: HTTP {e.code}", file=sys.stderr)
|
|
93
|
-
print(f"", file=sys.stderr)
|
|
94
|
-
print(f"Install manually from:", file=sys.stderr)
|
|
95
|
-
print(f" https://github.com/{REPO}/releases/tag/v{__version__}", file=sys.stderr)
|
|
96
|
-
sys.exit(1)
|
|
97
|
-
except Exception as e:
|
|
98
|
-
print(f"Download failed: {e}", file=sys.stderr)
|
|
99
|
-
sys.exit(1)
|
|
100
|
-
|
|
101
|
-
with open(binary_path, "wb") as f:
|
|
102
|
-
f.write(data)
|
|
103
|
-
|
|
104
|
-
# Make executable
|
|
105
|
-
if platform.system() != "Windows":
|
|
106
|
-
st = os.stat(binary_path)
|
|
107
|
-
os.chmod(binary_path, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
108
|
-
|
|
109
|
-
print(f"Installed factorly to {binary_path}")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def main():
|
|
113
|
-
"""Entry point: ensure binary exists, then run it."""
|
|
114
|
-
if not is_installed():
|
|
115
|
-
download_binary()
|
|
116
|
-
|
|
117
|
-
binary = get_binary_path()
|
|
118
|
-
if not os.path.exists(binary):
|
|
119
|
-
print("factorly binary not found after download attempt.", file=sys.stderr)
|
|
120
|
-
print("Run: pip install --force-reinstall factorly", file=sys.stderr)
|
|
121
|
-
sys.exit(127)
|
|
122
|
-
|
|
123
|
-
result = subprocess.run([binary] + sys.argv[1:])
|
|
124
|
-
sys.exit(result.returncode)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if __name__ == "__main__":
|
|
128
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|