jira-cli-toolkit 2.5.1__py3-none-any.whl
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.
- jira_cli_toolkit-2.5.1.dist-info/METADATA +212 -0
- jira_cli_toolkit-2.5.1.dist-info/RECORD +23 -0
- jira_cli_toolkit-2.5.1.dist-info/WHEEL +4 -0
- jira_cli_toolkit-2.5.1.dist-info/entry_points.txt +4 -0
- jira_cli_toolkit-2.5.1.dist-info/licenses/LICENSE +661 -0
- jira_cli_toolkit-2.5.1.dist-info/licenses/NOTICE +25 -0
- jsup/__init__.py +7 -0
- jsup/api.py +352 -0
- jsup/api_spec.py +309 -0
- jsup/application.py +330 -0
- jsup/auth.py +248 -0
- jsup/cli.py +103 -0
- jsup/client.py +412 -0
- jsup/commands.py +369 -0
- jsup/completion.py +56 -0
- jsup/config.py +160 -0
- jsup/credentials.py +125 -0
- jsup/data/callback.json +13 -0
- jsup/fields.py +204 -0
- jsup/maintenance.py +105 -0
- jsup/templates.py +89 -0
- jsup/ui.py +170 -0
- jsup/update.py +328 -0
jsup/ui.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Rich terminal UI: tables, panels, pickers. Plain output when piped."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import csv
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from .client import adf_to_text
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
err_console = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
STATUS_STYLE = {"new": "cyan", "indeterminate": "yellow", "done": "green"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def status_text(status: dict) -> Text:
|
|
22
|
+
name = (status or {}).get("name", "?")
|
|
23
|
+
cat = ((status or {}).get("statusCategory") or {}).get("key", "")
|
|
24
|
+
return Text(name, style=STATUS_STYLE.get(cat, ""))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def emit(data, render) -> None:
|
|
28
|
+
"""JSON to stdout for scripts is handled by callers; this is human output."""
|
|
29
|
+
render(data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dump_json(data) -> None:
|
|
33
|
+
print(json.dumps(data, indent=2))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_rows(data):
|
|
37
|
+
items = data if isinstance(data, list) else next((data[k] for k in ("issues", "values", "comments", "projects") if isinstance(data.get(k), list)), [])
|
|
38
|
+
rows = []
|
|
39
|
+
for item in items:
|
|
40
|
+
if not isinstance(item, dict):
|
|
41
|
+
rows.append({"value": str(item)})
|
|
42
|
+
continue
|
|
43
|
+
row = {**item, **item.get("fields", {})}
|
|
44
|
+
row.pop("fields", None)
|
|
45
|
+
for key, value in row.items():
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
row[key] = value.get("displayName", value.get("name", value.get("value", json.dumps(value))))
|
|
48
|
+
elif isinstance(value, list):
|
|
49
|
+
row[key] = json.dumps(value, ensure_ascii=False)
|
|
50
|
+
rows.append(row)
|
|
51
|
+
return rows
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def csv_data(data, columns=None):
|
|
55
|
+
rows = list_rows(data)
|
|
56
|
+
names = columns.split(",") if columns else list(dict.fromkeys(k for row in rows for k in row))
|
|
57
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=names, extrasaction="ignore", lineterminator="\n")
|
|
58
|
+
writer.writeheader()
|
|
59
|
+
writer.writerows(rows)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def issues_table(data, title="Issues", columns=None) -> None:
|
|
63
|
+
issues = data.get("issues", [])
|
|
64
|
+
if columns:
|
|
65
|
+
simple_table(f"{title} ({len(issues)} fetched)", list_rows(data), columns.split(","))
|
|
66
|
+
return
|
|
67
|
+
remaining = " · more available" if data.get("isLast") is False or data.get("nextPageToken") else ""
|
|
68
|
+
total = f", total={data['total']}" if "total" in data else ""
|
|
69
|
+
t = Table(title=f"{title} ({len(issues)} fetched{total}{remaining})",
|
|
70
|
+
show_lines=False)
|
|
71
|
+
t.add_column("Key", style="bold", no_wrap=True)
|
|
72
|
+
t.add_column("Status")
|
|
73
|
+
t.add_column("Summary")
|
|
74
|
+
t.add_column("Assignee")
|
|
75
|
+
t.add_column("Updated", no_wrap=True)
|
|
76
|
+
for i in issues:
|
|
77
|
+
f = i.get("fields", {})
|
|
78
|
+
t.add_row(
|
|
79
|
+
i.get("key", "?"),
|
|
80
|
+
status_text(f.get("status", {})),
|
|
81
|
+
(f.get("summary") or "")[:90],
|
|
82
|
+
(f.get("assignee") or {}).get("displayName", "-") if f.get("assignee") else "-",
|
|
83
|
+
str(f.get("updated", ""))[:10],
|
|
84
|
+
)
|
|
85
|
+
console.print(t)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def issue_panel(d) -> None:
|
|
89
|
+
f = d.get("fields", {})
|
|
90
|
+
desc = adf_to_text(f.get("description"))
|
|
91
|
+
body = (
|
|
92
|
+
f"[bold]{d.get('key')}[/]: {f.get('summary', '')}\n"
|
|
93
|
+
f"status={(f.get('status') or {}).get('name')} "
|
|
94
|
+
f"type={(f.get('issuetype') or {}).get('name')} "
|
|
95
|
+
f"priority={(f.get('priority') or {}).get('name')}\n"
|
|
96
|
+
f"assignee={((f.get('assignee') or {}).get('displayName')) or '-'} "
|
|
97
|
+
f"labels={', '.join(f.get('labels', [])) or '-'} "
|
|
98
|
+
f"components={', '.join(c.get('name','') for c in f.get('components', [])) or '-'}"
|
|
99
|
+
)
|
|
100
|
+
if desc:
|
|
101
|
+
body += f"\n\n{desc[:1500]}"
|
|
102
|
+
n_comments = (d.get("fields", {}).get("comment") or {}).get("total", "?")
|
|
103
|
+
console.print(Panel(body, title="Issue", subtitle=f"comments: {n_comments}"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def comments_table(d) -> None:
|
|
107
|
+
t = Table(title="Comments", show_lines=True)
|
|
108
|
+
t.add_column("ID", no_wrap=True)
|
|
109
|
+
t.add_column("Author")
|
|
110
|
+
t.add_column("Body")
|
|
111
|
+
for c in d.get("comments", []):
|
|
112
|
+
t.add_row(str(c.get("id")),
|
|
113
|
+
(c.get("author") or {}).get("displayName", "?"),
|
|
114
|
+
adf_to_text(c.get("body"))[:400])
|
|
115
|
+
console.print(t)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def boards_table(d) -> None:
|
|
119
|
+
t = Table(title="Boards")
|
|
120
|
+
t.add_column("ID", no_wrap=True)
|
|
121
|
+
t.add_column("Type")
|
|
122
|
+
t.add_column("Name")
|
|
123
|
+
for b in d.get("values", []):
|
|
124
|
+
t.add_row(str(b["id"]), b.get("type", ""), b.get("name", ""))
|
|
125
|
+
console.print(t)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sprints_table(d) -> None:
|
|
129
|
+
t = Table(title="Sprints")
|
|
130
|
+
t.add_column("ID", no_wrap=True)
|
|
131
|
+
t.add_column("State")
|
|
132
|
+
t.add_column("Name")
|
|
133
|
+
t.add_column("Goal")
|
|
134
|
+
for s in d.get("values", []):
|
|
135
|
+
state = s.get("state", "")
|
|
136
|
+
style = {"active": "green", "future": "cyan", "closed": "dim"}.get(state, "")
|
|
137
|
+
t.add_row(str(s["id"]), f"[{style}]{state}[/]" if style else state,
|
|
138
|
+
s.get("name", ""), (s.get("goal") or "")[:60])
|
|
139
|
+
console.print(t)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def simple_table(title, rows, cols) -> None:
|
|
143
|
+
t = Table(title=title)
|
|
144
|
+
for c in cols:
|
|
145
|
+
t.add_column(c)
|
|
146
|
+
for r in rows:
|
|
147
|
+
t.add_row(*[str(r.get(c, "")) for c in cols])
|
|
148
|
+
console.print(t)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def pick(prompt_text: str, options: list[str]) -> int:
|
|
152
|
+
"""Numbered picker; returns chosen index. Aborts cleanly on Ctrl-C/empty."""
|
|
153
|
+
for n, label in enumerate(options, 1):
|
|
154
|
+
console.print(f" [bold]{n}[/]. {label}")
|
|
155
|
+
try:
|
|
156
|
+
raw = input(f"{prompt_text} [1-{len(options)}]: ").strip()
|
|
157
|
+
except (KeyboardInterrupt, EOFError):
|
|
158
|
+
err_console.print("\nAborted.")
|
|
159
|
+
sys.exit(130)
|
|
160
|
+
if not raw.isdigit() or not 1 <= int(raw) <= len(options):
|
|
161
|
+
err_console.print("Aborted: pick a number from the list.")
|
|
162
|
+
sys.exit(2)
|
|
163
|
+
return int(raw) - 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def confirm(prompt_text: str) -> bool:
|
|
167
|
+
try:
|
|
168
|
+
return input(f"{prompt_text} [y/N]: ").strip().lower() in ("y", "yes")
|
|
169
|
+
except (KeyboardInterrupt, EOFError):
|
|
170
|
+
return False
|
jsup/update.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Explicit updates from verified GitHub Release assets; ordinary commands never update."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from urllib.parse import urlsplit, quote
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from packaging.version import Version, InvalidVersion
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
|
|
21
|
+
REPO = 'User17745/jira-cli-toolkit'
|
|
22
|
+
API = 'https://api.github.com/repos/'+REPO
|
|
23
|
+
MAX_BINARY = 250 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UpdateError(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DISTRIBUTION='jira-cli-toolkit'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _distribution():
|
|
34
|
+
"""Package name the running code was installed under; jsup before 2.5.1."""
|
|
35
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
36
|
+
for name in (DISTRIBUTION,'jsup'):
|
|
37
|
+
try:
|
|
38
|
+
distribution(name)
|
|
39
|
+
return name
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
continue
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def installation():
|
|
46
|
+
if getattr(sys,'frozen',False):
|
|
47
|
+
method='standalone'
|
|
48
|
+
else:
|
|
49
|
+
prefix=Path(sys.prefix)
|
|
50
|
+
executable=str(Path(sys.executable).absolute()).replace('\\','/')
|
|
51
|
+
if (prefix/'pipx_metadata.json').is_file() or '/pipx/venvs/' in executable:
|
|
52
|
+
method='pipx'
|
|
53
|
+
elif (prefix/'uv-receipt.toml').is_file() or '/uv/tools/' in executable:
|
|
54
|
+
method='uv'
|
|
55
|
+
else:
|
|
56
|
+
method='python'
|
|
57
|
+
arch={'amd64':'x86_64','x86_64':'x86_64','aarch64':'arm64','arm64':'arm64'}.get(platform.machine().lower(),platform.machine().lower())
|
|
58
|
+
return dict(version=__version__,method=method,distribution=None if method=='standalone' else _distribution(),os={'Darwin':'macos','Linux':'linux','Windows':'windows'}.get(platform.system(),'unsupported'),
|
|
59
|
+
arch=arch,executable=str(Path(sys.executable).resolve()),python=sys.version.split()[0])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def github_token():
|
|
63
|
+
token=os.getenv('GH_TOKEN') or os.getenv('GITHUB_TOKEN')
|
|
64
|
+
if not token and shutil.which('gh'):
|
|
65
|
+
try:
|
|
66
|
+
result=subprocess.run(['gh','auth','token','--hostname','github.com'],capture_output=True,text=True,timeout=10)
|
|
67
|
+
if result.returncode==0: token=result.stdout.strip()
|
|
68
|
+
except (OSError,subprocess.TimeoutExpired):
|
|
69
|
+
pass
|
|
70
|
+
return token
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Releases:
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self.session=requests.Session()
|
|
76
|
+
self.session.headers.update({'Accept':'application/vnd.github+json','X-GitHub-Api-Version':'2022-11-28'})
|
|
77
|
+
token=github_token()
|
|
78
|
+
if token: self.session.headers['Authorization']='Bearer '+token
|
|
79
|
+
|
|
80
|
+
def close(self): self.session.close()
|
|
81
|
+
|
|
82
|
+
def get(self,path):
|
|
83
|
+
response=self.session.get(API+path,timeout=(10,30))
|
|
84
|
+
if response.status_code==404:
|
|
85
|
+
raise UpdateError('Release not found or private repository access unavailable. Set GH_TOKEN with repository read access, or authenticate gh for the owning account.')
|
|
86
|
+
if response.status_code in (403,429):
|
|
87
|
+
raise UpdateError('GitHub access denied or rate limited. Check repository access and retry after the rate-limit window.')
|
|
88
|
+
if response.status_code!=200:
|
|
89
|
+
raise UpdateError(f'GitHub returned HTTP {response.status_code}.')
|
|
90
|
+
try: data=response.json()
|
|
91
|
+
except ValueError: raise UpdateError('GitHub returned malformed JSON.') from None
|
|
92
|
+
if not isinstance(data,dict): raise UpdateError('Unexpected GitHub release response.')
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
def release(self,version=None):
|
|
96
|
+
tag='v'+version.removeprefix('v') if version else None
|
|
97
|
+
return self.get('/releases/tags/'+quote(tag,safe='') if tag else '/releases/latest')
|
|
98
|
+
|
|
99
|
+
def download(self,asset,destination,max_size):
|
|
100
|
+
aid=asset.get('id')
|
|
101
|
+
if not isinstance(aid,int) or aid<1 or not isinstance(asset.get('size'),int) or not 0<asset['size']<=max_size:
|
|
102
|
+
raise UpdateError('Release asset has invalid ID or size.')
|
|
103
|
+
# Construct the canonical URL rather than trusting a manifest-provided URL.
|
|
104
|
+
response=self.session.get(API+'/releases/assets/'+str(aid),headers={'Accept':'application/octet-stream'},
|
|
105
|
+
stream=True,timeout=(10,60),allow_redirects=False)
|
|
106
|
+
if response.status_code in (301,302,303,307,308):
|
|
107
|
+
location=response.headers.get('Location','')
|
|
108
|
+
parsed=urlsplit(location)
|
|
109
|
+
if parsed.scheme!='https' or parsed.hostname not in {'release-assets.githubusercontent.com','objects.githubusercontent.com','github.com'} or parsed.username:
|
|
110
|
+
response.close(); raise UpdateError('Unexpected release download host.')
|
|
111
|
+
response.close()
|
|
112
|
+
# Never forward the GitHub token to object storage.
|
|
113
|
+
response=requests.get(location,stream=True,timeout=(10,60),allow_redirects=False)
|
|
114
|
+
try:
|
|
115
|
+
if response.status_code!=200: raise UpdateError(f'Asset download failed (HTTP {response.status_code}).')
|
|
116
|
+
size=0; digest=hashlib.sha256()
|
|
117
|
+
with open(destination,'wb') as stream:
|
|
118
|
+
for chunk in response.iter_content(1024*1024):
|
|
119
|
+
if not chunk: continue
|
|
120
|
+
size+=len(chunk)
|
|
121
|
+
if size>max_size or size>asset['size']: raise UpdateError('Release download exceeds its declared size.')
|
|
122
|
+
digest.update(chunk); stream.write(chunk)
|
|
123
|
+
stream.flush(); os.fsync(stream.fileno())
|
|
124
|
+
if size!=asset['size']: raise UpdateError('Interrupted or truncated release download.')
|
|
125
|
+
return size,digest.hexdigest()
|
|
126
|
+
finally:
|
|
127
|
+
response.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def verify_manifest(manifest,release):
|
|
131
|
+
if not isinstance(manifest,dict) or manifest.get('schema_version')!=1:
|
|
132
|
+
raise UpdateError('Unsupported release manifest schema.')
|
|
133
|
+
try:
|
|
134
|
+
version=Version(manifest['version'])
|
|
135
|
+
tag_version=Version(release['tag_name'].removeprefix('v'))
|
|
136
|
+
except (KeyError,InvalidVersion): raise UpdateError('Invalid release version metadata.') from None
|
|
137
|
+
if version!=tag_version or manifest.get('tag')!=release['tag_name']:
|
|
138
|
+
raise UpdateError('Release tag and manifest version do not agree.')
|
|
139
|
+
if manifest.get('repository')!=REPO or not isinstance(manifest.get('artifacts'),list):
|
|
140
|
+
raise UpdateError('Invalid release repository or artifacts.')
|
|
141
|
+
names=set()
|
|
142
|
+
for item in manifest['artifacts']:
|
|
143
|
+
name=item.get('name','')
|
|
144
|
+
if not name or Path(name).name!=name or name in names or not isinstance(item.get('size'),int) or item['size']<=0 or len(item.get('sha256',''))!=64:
|
|
145
|
+
raise UpdateError('Invalid or duplicate release artifact metadata.')
|
|
146
|
+
names.add(name)
|
|
147
|
+
return version
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def probe(path,version):
|
|
151
|
+
env=os.environ.copy(); env['PYINSTALLER_RESET_ENVIRONMENT']='1'
|
|
152
|
+
try:
|
|
153
|
+
result=subprocess.run([str(path),'--version'],capture_output=True,text=True,timeout=45,env=env,check=True)
|
|
154
|
+
if Version(result.stdout.strip().split()[-1])!=Version(version):
|
|
155
|
+
raise UpdateError('Candidate binary reports the wrong version.')
|
|
156
|
+
subprocess.run([str(path),'--help'],capture_output=True,text=True,timeout=45,env=env,check=True)
|
|
157
|
+
except (OSError,subprocess.SubprocessError,InvalidVersion,IndexError):
|
|
158
|
+
raise UpdateError('Candidate executable failed its version/help checks.') from None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _preload_output():
|
|
162
|
+
"""Load everything that reports the result while the original executable is still in place.
|
|
163
|
+
|
|
164
|
+
A frozen build imports modules lazily from its own file. Once that file is replaced,
|
|
165
|
+
a first import fails to decompress, as 2.1.0 does after a successful update.
|
|
166
|
+
"""
|
|
167
|
+
from . import ui
|
|
168
|
+
for console in (ui.console, ui.err_console):
|
|
169
|
+
with console.capture():
|
|
170
|
+
console.print({'updated': True, 'version': '0.0.0'}, markup=False)
|
|
171
|
+
console.print('Update failed after replacement.', markup=False)
|
|
172
|
+
import json, traceback # noqa: F401 (error reporting paths)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def replace_binary(candidate,target,version):
|
|
177
|
+
target=Path(target)
|
|
178
|
+
if target.is_symlink(): raise UpdateError('Refusing to overwrite a symbolic-link installation. Update its owning manager.')
|
|
179
|
+
backup=target.with_name(target.name+'.previous')
|
|
180
|
+
lock=target.with_name(target.name+'.update-lock')
|
|
181
|
+
deferred = False
|
|
182
|
+
try:
|
|
183
|
+
lock.mkdir()
|
|
184
|
+
except FileExistsError: raise UpdateError('Another update is running (or left an update lock); inspect it before retrying.') from None
|
|
185
|
+
try:
|
|
186
|
+
if backup.exists(): raise UpdateError('A previous backup exists; preserve or remove it before another update.')
|
|
187
|
+
candidate.chmod(target.stat().st_mode & 0o777)
|
|
188
|
+
probe(candidate,version)
|
|
189
|
+
if os.name=='nt':
|
|
190
|
+
# Run a separate copy so the source candidate remains replaceable.
|
|
191
|
+
staging=Path(tempfile.mkdtemp(prefix='.jira-update-',dir=target.parent))
|
|
192
|
+
source=staging/'candidate.exe'; helper=staging/'helper.exe'
|
|
193
|
+
shutil.copy2(candidate,source); shutil.copy2(candidate,helper)
|
|
194
|
+
digest=hashlib.sha256(source.read_bytes()).hexdigest()
|
|
195
|
+
env=os.environ.copy(); env['PYINSTALLER_RESET_ENVIRONMENT']='1'
|
|
196
|
+
with open(staging/'helper.log','w') as log:
|
|
197
|
+
subprocess.Popen([str(helper),'--_apply-update',str(source),str(target),str(os.getpid()),version,digest],
|
|
198
|
+
stdout=log,stderr=log,env=env,creationflags=0x00000008)
|
|
199
|
+
deferred=True
|
|
200
|
+
return {'pending':True,'log':str(staging/'helper.log')}
|
|
201
|
+
_preload_output()
|
|
202
|
+
os.replace(target,backup)
|
|
203
|
+
try:
|
|
204
|
+
os.replace(candidate,target)
|
|
205
|
+
probe(target,version)
|
|
206
|
+
except Exception:
|
|
207
|
+
os.replace(backup,target)
|
|
208
|
+
raise UpdateError('Update failed after replacement; the previous executable was restored.') from None
|
|
209
|
+
# Preserve the backup for explicit executable rollback; config is untouched.
|
|
210
|
+
finally:
|
|
211
|
+
if not deferred:
|
|
212
|
+
lock.rmdir()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def apply_windows_update(values):
|
|
216
|
+
"""Private helper entry point: wait for the parent, replace, validate, rollback."""
|
|
217
|
+
if os.name!='nt' or not getattr(sys,'frozen',False) or len(values)!=5:
|
|
218
|
+
raise UpdateError('The update helper is supported only in a standalone Windows binary.')
|
|
219
|
+
source,target,pid,version,digest=values
|
|
220
|
+
source,target=Path(source).absolute(),Path(target).absolute()
|
|
221
|
+
lock=target.with_name(target.name+'.update-lock'); backup=target.with_name(target.name+'.previous')
|
|
222
|
+
if source.parent.parent!=target.parent or not source.parent.name.startswith('.jira-update-') or not lock.is_dir() or target.is_symlink():
|
|
223
|
+
raise UpdateError('Invalid staged update paths.')
|
|
224
|
+
if Version(version)!=Version(__version__) or hashlib.sha256(source.read_bytes()).hexdigest()!=digest:
|
|
225
|
+
raise UpdateError('Staged update checksum/version mismatch.')
|
|
226
|
+
import ctypes
|
|
227
|
+
from ctypes import wintypes
|
|
228
|
+
kernel=ctypes.windll.kernel32
|
|
229
|
+
kernel.OpenProcess.argtypes=[wintypes.DWORD,wintypes.BOOL,wintypes.DWORD]
|
|
230
|
+
kernel.OpenProcess.restype=wintypes.HANDLE
|
|
231
|
+
kernel.WaitForSingleObject.argtypes=[wintypes.HANDLE,wintypes.DWORD]
|
|
232
|
+
kernel.CloseHandle.argtypes=[wintypes.HANDLE]
|
|
233
|
+
handle=kernel.OpenProcess(0x00100000,False,int(pid))
|
|
234
|
+
if handle:
|
|
235
|
+
try:
|
|
236
|
+
if kernel.WaitForSingleObject(handle,120000)!=0:
|
|
237
|
+
raise UpdateError('The old executable did not exit; installation is unchanged.')
|
|
238
|
+
finally:
|
|
239
|
+
kernel.CloseHandle(handle)
|
|
240
|
+
moved=False
|
|
241
|
+
try:
|
|
242
|
+
for attempt in range(20):
|
|
243
|
+
try:
|
|
244
|
+
os.replace(target,backup)
|
|
245
|
+
moved=True
|
|
246
|
+
break
|
|
247
|
+
except PermissionError:
|
|
248
|
+
if attempt==19: raise
|
|
249
|
+
time.sleep(0.5)
|
|
250
|
+
os.replace(source,target)
|
|
251
|
+
probe(target,version)
|
|
252
|
+
print('Update completed; previous executable retained at '+str(backup))
|
|
253
|
+
except Exception:
|
|
254
|
+
if moved:
|
|
255
|
+
os.replace(backup,target)
|
|
256
|
+
raise UpdateError('Windows update failed; the previous executable was retained/restored. See helper.log.') from None
|
|
257
|
+
finally:
|
|
258
|
+
lock.rmdir()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def handle(args):
|
|
262
|
+
installed=installation()
|
|
263
|
+
if args.info: return installed
|
|
264
|
+
try: requested=Version(args.version.removeprefix('v')) if args.version else None
|
|
265
|
+
except InvalidVersion: raise UpdateError('Use a valid release version.') from None
|
|
266
|
+
if requested and requested.is_prerelease and not args.prerelease:
|
|
267
|
+
raise UpdateError('Prerelease versions require --prerelease.')
|
|
268
|
+
if requested and requested<Version(__version__) and not args.allow_downgrade:
|
|
269
|
+
raise UpdateError('Downgrades require --allow-downgrade.')
|
|
270
|
+
api=Releases()
|
|
271
|
+
try:
|
|
272
|
+
release=api.release(args.version)
|
|
273
|
+
if release.get('draft'): raise UpdateError('Draft releases cannot be installed.')
|
|
274
|
+
if release.get('prerelease') and not args.prerelease: raise UpdateError('Prerelease requires explicit --prerelease.')
|
|
275
|
+
assets={a['name']:a for a in release.get('assets',[]) if a.get('state')=='uploaded'}
|
|
276
|
+
if 'manifest.json' not in assets: raise UpdateError('Release is incomplete: manifest.json is missing.')
|
|
277
|
+
with tempfile.TemporaryDirectory(prefix='jira-update-') as temp:
|
|
278
|
+
manifest_path=Path(temp)/'manifest.json'
|
|
279
|
+
api.download(assets['manifest.json'],manifest_path,2*1024*1024)
|
|
280
|
+
try: manifest=json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
281
|
+
except (ValueError,UnicodeError): raise UpdateError('Invalid release manifest.') from None
|
|
282
|
+
version=verify_manifest(manifest,release)
|
|
283
|
+
if version.is_prerelease and not args.prerelease: raise UpdateError('Prerelease requires --prerelease.')
|
|
284
|
+
if version<Version(__version__) and not args.allow_downgrade: raise UpdateError('Release is older than this installation; use --allow-downgrade explicitly.')
|
|
285
|
+
available=version!=Version(__version__)
|
|
286
|
+
result={**installed,'available_version':str(version),'update_available':available,'release_url':release.get('html_url'),'updated':False}
|
|
287
|
+
if args.check or not available: return result
|
|
288
|
+
if installed['method']!='standalone':
|
|
289
|
+
wheel=next((a for a in manifest['artifacts'] if a.get('kind')=='wheel'),None)
|
|
290
|
+
if not wheel: raise UpdateError('Release has no Python wheel for this installation.')
|
|
291
|
+
# Exact versions also select release candidates, which pip skips by default.
|
|
292
|
+
package=f'{DISTRIBUTION}=={version}'
|
|
293
|
+
instructions={'pipx':f"pipx install --force '{package}'", 'uv':f"uv tool install --force '{package}'",
|
|
294
|
+
'python':f"{sys.executable} -m pip install --upgrade '{package}'"}
|
|
295
|
+
# Never mutate an unrelated Python or manager environment; the owning manager upgrades it.
|
|
296
|
+
result.update(instructions=instructions[installed['method']], wheel=wheel['name'],
|
|
297
|
+
note=f'{DISTRIBUTION} is published on PyPI. To install offline instead, download {wheel["name"]} '
|
|
298
|
+
'from this release, verify its manifest checksum, and pass its path to the same command.')
|
|
299
|
+
if installed.get('distribution')=='jsup':
|
|
300
|
+
# Both distributions ship the jsup module, so the old one must go first; side by side,
|
|
301
|
+
# uninstalling either later would remove files the other still uses.
|
|
302
|
+
remove={'pipx':'pipx uninstall jsup','uv':'uv tool uninstall jsup',
|
|
303
|
+
'python':f'{sys.executable} -m pip uninstall jsup'}[installed['method']]
|
|
304
|
+
result['migration']=(f"This installation uses the package's old name, jsup. Switch once: {remove}, "
|
|
305
|
+
f"then {instructions[installed['method']]}. Configuration and saved credentials are kept.")
|
|
306
|
+
return result
|
|
307
|
+
match=[a for a in manifest['artifacts'] if a.get('kind')=='binary' and a.get('os')==installed['os'] and a.get('arch')==installed['arch']]
|
|
308
|
+
if len(match)!=1: raise UpdateError('No compatible binary for this platform/architecture.')
|
|
309
|
+
item=match[0]
|
|
310
|
+
if item['name'] not in assets: raise UpdateError('Release binary is missing.')
|
|
311
|
+
if not args.yes:
|
|
312
|
+
if args.no_input or args.json or not sys.stdin.isatty(): raise UpdateError('Pass --yes to confirm the executable update.')
|
|
313
|
+
from .ui import confirm
|
|
314
|
+
if not confirm(f'Install {version}?'): raise UpdateError('Update declined.')
|
|
315
|
+
target=Path(sys.executable).absolute()
|
|
316
|
+
with tempfile.TemporaryDirectory(prefix='.jira-update-',dir=target.parent) as staged:
|
|
317
|
+
candidate=Path(staged)/item['name']
|
|
318
|
+
size,digest=api.download(assets[item['name']],candidate,MAX_BINARY)
|
|
319
|
+
if size!=item['size'] or digest!=item['sha256']: raise UpdateError('Release binary checksum/size mismatch; installation was not changed.')
|
|
320
|
+
candidate.chmod(0o700)
|
|
321
|
+
replacement=replace_binary(candidate,target,str(version))
|
|
322
|
+
if replacement:
|
|
323
|
+
result.update(replacement)
|
|
324
|
+
else:
|
|
325
|
+
result['updated']=True
|
|
326
|
+
return result
|
|
327
|
+
finally:
|
|
328
|
+
api.close()
|