@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21
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/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- package/skill/sentry-digest.sh +125 -12
package/scripts/sentry_digest.py
CHANGED
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Sentry issue
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
2
|
+
"""Sentry issue scanner for the s4l Sentry project (org mediar-n5).
|
|
3
|
+
|
|
4
|
+
Pull-only step in the sentry-digest pipeline (see skill/sentry-digest.sh and
|
|
5
|
+
skill/SENTRY-DIGEST-SKILL.md). This script does NOT investigate, does NOT
|
|
6
|
+
email, and does NOT write the ledger. It just:
|
|
7
|
+
|
|
8
|
+
1. Pulls unresolved level:error/level:fatal issues ("critical Sentry issues"
|
|
9
|
+
per user instruction 2026-07-07; level:warning menubar pings like
|
|
10
|
+
"draft_stuck" are excluded, they're a different kind of event already
|
|
11
|
+
surfaced elsewhere).
|
|
12
|
+
2. Fetches distinct install_id impact per issue (Sentry's built-in userCount
|
|
13
|
+
is 0 across the board for this project; installs are tagged via
|
|
14
|
+
install_id instead).
|
|
15
|
+
3. Diffs against the ledger to classify each issue as NEW / GROWING / STABLE.
|
|
16
|
+
4. Writes the full scan result to a JSON file on disk and prints a one-line
|
|
17
|
+
summary for the bash wrapper to decide whether to spawn Claude for
|
|
18
|
+
investigation.
|
|
19
|
+
|
|
20
|
+
The actual investigation (read the crashing code, check git log, judge
|
|
21
|
+
actionability, draft a human-readable email, send it, write the ledger back)
|
|
22
|
+
is done by a spawned Claude Code session per skill/SENTRY-DIGEST-SKILL.md,
|
|
23
|
+
mirroring how ~/fazm/inbox/skill/check-query-insights.sh divides labor
|
|
24
|
+
between a mechanical pull step and an investigative Claude step.
|
|
23
25
|
|
|
24
26
|
Usage:
|
|
25
|
-
python3 scripts/sentry_digest.py
|
|
26
|
-
python3 scripts/sentry_digest.py --dry-run # print what would happen, no email, no ledger write
|
|
27
|
-
|
|
28
|
-
Patterned after strike_alert.py: same Gmail token, same dash-scrubbing,
|
|
29
|
-
default recipient i@m13v.com.
|
|
27
|
+
python3 scripts/sentry_digest.py --out /path/to/scan.json
|
|
30
28
|
"""
|
|
31
29
|
|
|
32
30
|
import argparse
|
|
33
|
-
import base64
|
|
34
31
|
import json
|
|
35
32
|
import os
|
|
36
33
|
import subprocess
|
|
@@ -39,39 +36,26 @@ import urllib.error
|
|
|
39
36
|
import urllib.parse
|
|
40
37
|
import urllib.request
|
|
41
38
|
from datetime import datetime, timezone
|
|
42
|
-
from email.mime.text import MIMEText
|
|
43
39
|
|
|
44
40
|
SENTRY_ORG = "mediar-n5"
|
|
45
41
|
SENTRY_PROJECT = "s4l"
|
|
46
42
|
SENTRY_PROJECT_ID = "4511598804336640"
|
|
47
43
|
SENTRY_API = "https://sentry.io/api/0"
|
|
48
44
|
|
|
49
|
-
# "Critical Sentry issues" = level:error or level:fatal.
|
|
50
|
-
# (the synthetic menubar/autopilot signal pings), which are a different kind
|
|
51
|
-
# of event and already have their own surfacing (menubar UI, dashboard,
|
|
52
|
-
# on-demand Sentry queries during customer debugging).
|
|
45
|
+
# "Critical Sentry issues" = level:error or level:fatal.
|
|
53
46
|
ISSUE_QUERY = "is:unresolved level:[error,fatal]"
|
|
54
47
|
|
|
55
48
|
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
56
49
|
STATE_DIR = os.path.join(REPO_DIR, "scripts", "state")
|
|
57
50
|
LEDGER_PATH = os.path.join(STATE_DIR, "sentry_digest_ledger.json")
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# Growth threshold: an issue re-alerts if event count grew by at least this
|
|
64
|
-
# many events AND by at least this relative fraction since the ledger snapshot.
|
|
52
|
+
# Growth threshold: an issue counts as GROWING if event count grew by at
|
|
53
|
+
# least this many events AND by at least this relative fraction since the
|
|
54
|
+
# ledger snapshot.
|
|
65
55
|
GROWTH_MIN_DELTA = 5
|
|
66
56
|
GROWTH_MIN_RATIO = 1.2
|
|
67
57
|
|
|
68
58
|
|
|
69
|
-
def _scrub_dashes(s):
|
|
70
|
-
if not s:
|
|
71
|
-
return s
|
|
72
|
-
return s.replace("—", ",").replace("–", ",")
|
|
73
|
-
|
|
74
|
-
|
|
75
59
|
def _sentry_token():
|
|
76
60
|
env_token = os.environ.get("SENTRY_AUTH_TOKEN")
|
|
77
61
|
if env_token:
|
|
@@ -103,16 +87,16 @@ def fetch_critical_issues(token):
|
|
|
103
87
|
|
|
104
88
|
|
|
105
89
|
def fetch_install_impact(issue_id, token):
|
|
106
|
-
"""Distinct install_id count
|
|
90
|
+
"""Distinct install_id count for one issue. Returns 0 if the tag is absent."""
|
|
107
91
|
try:
|
|
108
92
|
data = _sentry_get(f"/issues/{issue_id}/tags/install_id/", token)
|
|
109
|
-
return data.get("uniqueValues", 0)
|
|
93
|
+
return data.get("uniqueValues", 0)
|
|
110
94
|
except urllib.error.HTTPError as e:
|
|
111
95
|
if e.code == 404:
|
|
112
|
-
return 0
|
|
96
|
+
return 0
|
|
113
97
|
raise
|
|
114
98
|
except Exception:
|
|
115
|
-
return 0
|
|
99
|
+
return 0
|
|
116
100
|
|
|
117
101
|
|
|
118
102
|
def load_ledger():
|
|
@@ -125,95 +109,13 @@ def load_ledger():
|
|
|
125
109
|
return {"version": 1, "lastUpdated": None, "issues": {}}
|
|
126
110
|
|
|
127
111
|
|
|
128
|
-
def save_ledger(ledger):
|
|
129
|
-
os.makedirs(STATE_DIR, exist_ok=True)
|
|
130
|
-
tmp = LEDGER_PATH + ".tmp"
|
|
131
|
-
with open(tmp, "w") as f:
|
|
132
|
-
json.dump(ledger, f, indent=2)
|
|
133
|
-
os.replace(tmp, LEDGER_PATH)
|
|
134
|
-
|
|
135
|
-
|
|
136
112
|
def issue_link(short_id):
|
|
137
113
|
return f"https://mediar-n5.sentry.io/issues/?project={SENTRY_PROJECT_ID}&query={short_id}"
|
|
138
114
|
|
|
139
115
|
|
|
140
|
-
def gmail_service():
|
|
141
|
-
from google.auth.transport.requests import Request
|
|
142
|
-
from google.oauth2.credentials import Credentials
|
|
143
|
-
from googleapiclient.discovery import build
|
|
144
|
-
|
|
145
|
-
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
146
|
-
if creds.expired and creds.refresh_token:
|
|
147
|
-
creds.refresh(Request())
|
|
148
|
-
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
149
|
-
f.write(creds.to_json())
|
|
150
|
-
return build("gmail", "v1", credentials=creds)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def send_email(service, to_addr, subject, html_body):
|
|
154
|
-
msg = MIMEText(html_body, "html")
|
|
155
|
-
msg["to"] = to_addr
|
|
156
|
-
msg["subject"] = _scrub_dashes(subject)
|
|
157
|
-
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
158
|
-
result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
159
|
-
return result["id"]
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def build_rows_html(rows, growth=False):
|
|
163
|
-
cells = []
|
|
164
|
-
for r in rows:
|
|
165
|
-
link = issue_link(r["shortId"])
|
|
166
|
-
if growth:
|
|
167
|
-
cells.append(
|
|
168
|
-
f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
|
|
169
|
-
f"<td>{r['prevCount']} → {r['count']}</td>"
|
|
170
|
-
f"<td>{r['prevInstalls']} → {r['installs']}</td></tr>"
|
|
171
|
-
)
|
|
172
|
-
else:
|
|
173
|
-
cells.append(
|
|
174
|
-
f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
|
|
175
|
-
f"<td>{r['count']}</td><td>{r['installs']}</td></tr>"
|
|
176
|
-
)
|
|
177
|
-
return "".join(cells)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def build_html(new_rows, growing_rows, first_run, total_open):
|
|
181
|
-
today = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
182
|
-
sections = [f"<p><b>Critical Sentry issues (level:error/fatal), s4l project.</b> "
|
|
183
|
-
f"Open total: {total_open}. Generated {today}.</p>"]
|
|
184
|
-
|
|
185
|
-
if first_run:
|
|
186
|
-
sections.append("<p>First run: baselining every open critical issue. No investigation, "
|
|
187
|
-
"just a snapshot. Future runs only flag NEW or GROWING issues.</p>")
|
|
188
|
-
|
|
189
|
-
if new_rows and not first_run:
|
|
190
|
-
sections.append("<h3>New issues</h3><table border='1' cellpadding='6' "
|
|
191
|
-
"style='border-collapse:collapse'><tr><th>Issue</th><th>Events</th>"
|
|
192
|
-
"<th>Installs</th></tr>" + build_rows_html(new_rows) + "</table>")
|
|
193
|
-
|
|
194
|
-
if growing_rows:
|
|
195
|
-
sections.append("<h3>Growing issues</h3><table border='1' cellpadding='6' "
|
|
196
|
-
"style='border-collapse:collapse'><tr><th>Issue</th>"
|
|
197
|
-
"<th>Events (was → now)</th><th>Installs (was → now)</th></tr>"
|
|
198
|
-
+ build_rows_html(growing_rows, growth=True) + "</table>")
|
|
199
|
-
|
|
200
|
-
if first_run:
|
|
201
|
-
top = sorted(new_rows, key=lambda r: -r["installs"])[:10]
|
|
202
|
-
sections.append("<h3>Top 10 by installs affected (baseline snapshot)</h3>"
|
|
203
|
-
"<table border='1' cellpadding='6' style='border-collapse:collapse'>"
|
|
204
|
-
"<tr><th>Issue</th><th>Events</th><th>Installs</th></tr>"
|
|
205
|
-
+ build_rows_html(top) + "</table>")
|
|
206
|
-
|
|
207
|
-
sections.append("<p style='color:#888;font-size:12px'>Ranked by distinct install_id count, "
|
|
208
|
-
"not Sentry's userCount (unset for this project). level:warning menubar "
|
|
209
|
-
"signals are excluded; they're a different kind of event and still queryable "
|
|
210
|
-
"in Sentry directly during customer debugging.</p>")
|
|
211
|
-
return "<div style='font-family:sans-serif;max-width:800px'>" + "".join(sections) + "</div>"
|
|
212
|
-
|
|
213
|
-
|
|
214
116
|
def main():
|
|
215
117
|
parser = argparse.ArgumentParser()
|
|
216
|
-
parser.add_argument("--
|
|
118
|
+
parser.add_argument("--out", required=True, help="Path to write the scan result JSON.")
|
|
217
119
|
args = parser.parse_args()
|
|
218
120
|
|
|
219
121
|
token = _sentry_token()
|
|
@@ -229,74 +131,64 @@ def main():
|
|
|
229
131
|
ledger = load_ledger()
|
|
230
132
|
known = ledger.get("issues", {})
|
|
231
133
|
first_run = len(known) == 0
|
|
232
|
-
now_iso = datetime.now(timezone.utc).isoformat()
|
|
233
134
|
|
|
234
|
-
|
|
235
|
-
|
|
135
|
+
scanned = []
|
|
136
|
+
new_count = growing_count = 0
|
|
236
137
|
|
|
237
138
|
for issue in issues:
|
|
238
139
|
short_id = issue["shortId"]
|
|
140
|
+
numeric_id = issue["id"]
|
|
239
141
|
count = int(issue.get("count", 0))
|
|
240
|
-
installs
|
|
142
|
+
installs = fetch_install_impact(numeric_id, token)
|
|
241
143
|
prev = known.get(short_id)
|
|
242
144
|
|
|
243
145
|
if prev is None:
|
|
244
|
-
|
|
146
|
+
classification = "new"
|
|
147
|
+
new_count += 1
|
|
245
148
|
else:
|
|
246
149
|
prev_count = int(prev.get("lastEventCount", 0))
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
print(html)
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
service = gmail_service()
|
|
293
|
-
msg_id = send_email(service, NOTIFICATION_EMAIL, subject, html)
|
|
294
|
-
print(f"Email sent: {msg_id}")
|
|
295
|
-
|
|
296
|
-
ledger["issues"] = updated_issues
|
|
297
|
-
ledger["lastUpdated"] = now_iso
|
|
298
|
-
save_ledger(ledger)
|
|
299
|
-
print("Ledger written.")
|
|
150
|
+
is_growing = (
|
|
151
|
+
not first_run
|
|
152
|
+
and count - prev_count >= GROWTH_MIN_DELTA
|
|
153
|
+
and prev_count > 0
|
|
154
|
+
and count >= prev_count * GROWTH_MIN_RATIO
|
|
155
|
+
)
|
|
156
|
+
if is_growing:
|
|
157
|
+
classification = "growing"
|
|
158
|
+
growing_count += 1
|
|
159
|
+
else:
|
|
160
|
+
classification = "stable"
|
|
161
|
+
|
|
162
|
+
scanned.append({
|
|
163
|
+
"shortId": short_id,
|
|
164
|
+
"numericId": numeric_id,
|
|
165
|
+
"title": issue["title"],
|
|
166
|
+
"level": issue.get("level"),
|
|
167
|
+
"count": count,
|
|
168
|
+
"prevCount": int(prev["lastEventCount"]) if prev else None,
|
|
169
|
+
"installs": installs,
|
|
170
|
+
"prevInstalls": prev.get("lastInstallCount") if prev else None,
|
|
171
|
+
"classification": classification,
|
|
172
|
+
"link": issue_link(short_id),
|
|
173
|
+
"lastSeen": issue.get("lastSeen"),
|
|
174
|
+
"firstSeen": issue.get("firstSeen"),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
result = {
|
|
178
|
+
"scannedAt": datetime.now(timezone.utc).isoformat(),
|
|
179
|
+
"firstRun": first_run,
|
|
180
|
+
"totalOpen": len(issues),
|
|
181
|
+
"newCount": new_count,
|
|
182
|
+
"growingCount": growing_count,
|
|
183
|
+
"ledgerPath": LEDGER_PATH,
|
|
184
|
+
"issues": scanned,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
os.makedirs(os.path.dirname(args.out), exist_ok=True)
|
|
188
|
+
with open(args.out, "w") as f:
|
|
189
|
+
json.dump(result, f, indent=2)
|
|
190
|
+
|
|
191
|
+
print(f"firstRun={first_run} newCount={new_count} growingCount={growing_count} totalOpen={len(issues)} out={args.out}")
|
|
300
192
|
|
|
301
193
|
|
|
302
194
|
if __name__ == "__main__":
|
package/scripts/sentry_init.py
CHANGED
|
@@ -18,6 +18,24 @@ import os
|
|
|
18
18
|
_EMBEDDED_DSN = "https://4d44ac907262c6545cf8681703528d04@o4507617161314304.ingest.us.sentry.io/4511598804336640"
|
|
19
19
|
|
|
20
20
|
_initialized = False
|
|
21
|
+
_install_id_cache: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _install_id() -> str | None:
|
|
25
|
+
"""Cached install_id lookup, used to fingerprint events per-install (see
|
|
26
|
+
capture_exception/capture_message). Best-effort: "" (not None) is cached on
|
|
27
|
+
failure so a broken identity.py doesn't re-attempt the read on every call."""
|
|
28
|
+
global _install_id_cache
|
|
29
|
+
if _install_id_cache is not None:
|
|
30
|
+
return _install_id_cache or None
|
|
31
|
+
try:
|
|
32
|
+
from identity import get_identity
|
|
33
|
+
|
|
34
|
+
ident = get_identity() or {}
|
|
35
|
+
_install_id_cache = str(ident.get("install_id") or "")
|
|
36
|
+
except Exception:
|
|
37
|
+
_install_id_cache = ""
|
|
38
|
+
return _install_id_cache or None
|
|
21
39
|
|
|
22
40
|
|
|
23
41
|
def init() -> None:
|
|
@@ -79,12 +97,17 @@ def capture_exception(err, tags=None) -> None:
|
|
|
79
97
|
except Exception:
|
|
80
98
|
return
|
|
81
99
|
try:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
with sentry_sdk.push_scope() as scope:
|
|
101
|
+
for k, v in (tags or {}).items():
|
|
102
|
+
scope.set_tag(str(k), str(v))
|
|
103
|
+
# Mix install_id into the grouping key so the SAME exception on two
|
|
104
|
+
# different customer boxes lands in two different issues, not one
|
|
105
|
+
# conflated issue whose "latest event" can silently belong to a
|
|
106
|
+
# different install than the one you filtered for (default grouping
|
|
107
|
+
# is by exception type + location, which ignores tags entirely).
|
|
108
|
+
iid = _install_id()
|
|
109
|
+
if iid:
|
|
110
|
+
scope.fingerprint = ["{{ default }}", iid]
|
|
88
111
|
sentry_sdk.capture_exception(err)
|
|
89
112
|
except Exception:
|
|
90
113
|
return
|
|
@@ -100,7 +123,12 @@ def capture_message(message, level="error", tags=None, extra=None) -> None:
|
|
|
100
123
|
`extra` is a dict of larger structured values (e.g. the scheduled-task
|
|
101
124
|
registry summary) attached as event extras — tags are capped at 200 chars
|
|
102
125
|
and would truncate them. Added 2026-07-06: the "needs attention: missing"
|
|
103
|
-
events carried no registry detail, which cost hours on the Karol case.
|
|
126
|
+
events carried no registry detail, which cost hours on the Karol case.
|
|
127
|
+
|
|
128
|
+
Fingerprint: mixed with install_id (see capture_exception) — capture_message
|
|
129
|
+
events group by the message TEXT by default, so two installs hitting the
|
|
130
|
+
identically-worded stall alert (e.g. "autopilot stalled... sustained N
|
|
131
|
+
checks") would otherwise merge into one issue and hide per-install history."""
|
|
104
132
|
if not _initialized:
|
|
105
133
|
return
|
|
106
134
|
try:
|
|
@@ -108,14 +136,14 @@ def capture_message(message, level="error", tags=None, extra=None) -> None:
|
|
|
108
136
|
except Exception:
|
|
109
137
|
return
|
|
110
138
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
with sentry_sdk.push_scope() as scope:
|
|
140
|
+
for k, v in (tags or {}).items():
|
|
141
|
+
scope.set_tag(str(k), str(v))
|
|
142
|
+
for k, v in (extra or {}).items():
|
|
143
|
+
scope.set_extra(str(k), v)
|
|
144
|
+
iid = _install_id()
|
|
145
|
+
if iid:
|
|
146
|
+
scope.fingerprint = ["{{ default }}", iid]
|
|
119
147
|
sentry_sdk.capture_message(str(message), level=level)
|
|
120
148
|
except Exception:
|
|
121
149
|
return
|
|
@@ -1027,17 +1027,50 @@ def cmd_connect(args) -> dict:
|
|
|
1027
1027
|
except Exception as e:
|
|
1028
1028
|
return {"ok": False, "connected": False, "state": "error", "error": str(e), "cdp": CDP}
|
|
1029
1029
|
|
|
1030
|
-
# 1b.
|
|
1031
|
-
#
|
|
1032
|
-
#
|
|
1033
|
-
#
|
|
1034
|
-
#
|
|
1035
|
-
#
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1030
|
+
# 1b. Choose the connection PATH before doing any keychain work. There are
|
|
1031
|
+
# two mutually exclusive paths, and only one of them touches the keychain:
|
|
1032
|
+
# - IMPORT: copy an existing x.com session out of the user's everyday
|
|
1033
|
+
# browser. This reads that browser's "Safe Storage" key from the macOS
|
|
1034
|
+
# Keychain, so it (and ONLY it) is gated by the headless keychain
|
|
1035
|
+
# preflight below.
|
|
1036
|
+
# - MANUAL LOGIN: open the managed Chrome's own login page and let the
|
|
1037
|
+
# user sign in by hand. Reads NO keychain and needs NO everyday-browser
|
|
1038
|
+
# session, so it must NEVER be blocked by the keychain preflight.
|
|
1039
|
+
# Auto-import is only possible when some installed browser actually holds
|
|
1040
|
+
# an x.com session. On a fresh or remote/headless box there is nothing to
|
|
1041
|
+
# import, so gating manual login behind the keychain check dead-ended setup
|
|
1042
|
+
# on "keychain locked" even though there was no session to copy anyway
|
|
1043
|
+
# (the exact wedge that stalled the macstadium test box). We now skip
|
|
1044
|
+
# straight to manual login whenever there's nothing to import.
|
|
1045
|
+
#
|
|
1046
|
+
# Source selection:
|
|
1047
|
+
# - explicit --source X -> just that one (one keychain prompt)
|
|
1048
|
+
# - --source all -> the full chrome/arc/brave/edge sweep (legacy)
|
|
1049
|
+
# - no --source (default) -> the browser(s) that ACTUALLY hold an x.com
|
|
1050
|
+
# session, so we prompt the keychain for exactly the right one.
|
|
1051
|
+
if args.source == "all":
|
|
1052
|
+
sources = AUTO_SOURCES
|
|
1053
|
+
has_importable = True
|
|
1054
|
+
elif args.source:
|
|
1055
|
+
sources = [args.source]
|
|
1056
|
+
has_importable = True
|
|
1057
|
+
else:
|
|
1058
|
+
_srcs = _list_sources()
|
|
1059
|
+
_with_session = [s["spec"] for s in _srcs if s.get("x_session")]
|
|
1060
|
+
sources = _with_session or ["chrome:Default"]
|
|
1061
|
+
has_importable = bool(_with_session)
|
|
1062
|
+
|
|
1063
|
+
manual_login = bool(getattr(args, "manual_login", False))
|
|
1064
|
+
# Attempt an auto-import ONLY when the user didn't force manual login AND
|
|
1065
|
+
# there is a real session to copy. Otherwise fall through to manual login.
|
|
1066
|
+
will_import = has_importable and not manual_login
|
|
1067
|
+
|
|
1068
|
+
# 1c. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02) — relevant
|
|
1069
|
+
# ONLY to the import path. copy_browser_cookies.py must read the per-browser
|
|
1070
|
+
# Safe Storage entry from the OS keychain; SSH/launchd-invoked processes get
|
|
1071
|
+
# errSecAuthFailed silently (no prompt). The manual-login path skips this.
|
|
1072
|
+
if will_import and _is_headless():
|
|
1073
|
+
# Probe Chrome (the autoposter default); if that's denied, the rest are too.
|
|
1041
1074
|
kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
|
|
1042
1075
|
if not kc_ok:
|
|
1043
1076
|
return {
|
|
@@ -1053,28 +1086,17 @@ def cmd_connect(args) -> dict:
|
|
|
1053
1086
|
"or another headless context). No GUI prompt is shown for this — macOS "
|
|
1054
1087
|
"denies access silently. To fix, run this once in the same session:\n"
|
|
1055
1088
|
" security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
|
|
1056
|
-
"Then re-run connect_x.
|
|
1057
|
-
"
|
|
1058
|
-
"`security set-keychain-settings -lut 0`."
|
|
1089
|
+
"Then re-run connect_x. Or connect X with manual login instead — that "
|
|
1090
|
+
"signs in directly in the autoposter's own browser and needs no keychain."
|
|
1059
1091
|
),
|
|
1060
1092
|
"remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
|
|
1061
1093
|
"cdp": CDP,
|
|
1062
1094
|
}
|
|
1063
1095
|
|
|
1064
|
-
# 2. Import from the user's everyday browser
|
|
1065
|
-
# -
|
|
1066
|
-
# - --source all -> the full chrome/arc/brave/edge sweep (legacy)
|
|
1067
|
-
# - no --source (the default) -> auto-pick the browser(s) that ACTUALLY
|
|
1068
|
-
# hold an x.com session, so we prompt the keychain for exactly the right
|
|
1069
|
-
# one instead of blindly walking all four and prompting per browser.
|
|
1070
|
-
if args.source == "all":
|
|
1071
|
-
sources = AUTO_SOURCES
|
|
1072
|
-
elif args.source:
|
|
1073
|
-
sources = [args.source]
|
|
1074
|
-
else:
|
|
1075
|
-
sources = _auto_pick_sources()
|
|
1096
|
+
# 2. Import from the user's everyday browser (skipped entirely when we're on
|
|
1097
|
+
# the manual-login path — `will_import` False makes this loop a no-op).
|
|
1076
1098
|
attempts = []
|
|
1077
|
-
for src in sources:
|
|
1099
|
+
for src in (sources if will_import else []):
|
|
1078
1100
|
res = _import_from(src)
|
|
1079
1101
|
copied = res.get("stdout", "")
|
|
1080
1102
|
detail = copied or res.get("error") or res.get("stderr")
|
|
@@ -1139,8 +1161,15 @@ def cmd_connect(args) -> dict:
|
|
|
1139
1161
|
rolled_up_error_type = (
|
|
1140
1162
|
next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
|
|
1141
1163
|
)
|
|
1142
|
-
|
|
1143
|
-
|
|
1164
|
+
# Open a focused login window when the user asked for manual login, when the
|
|
1165
|
+
# keychain prompt was denied (import can't proceed), OR when there was nothing
|
|
1166
|
+
# to import in the first place — in that last case manual login is the ONLY
|
|
1167
|
+
# path that can work, so we must open the window rather than dead-end.
|
|
1168
|
+
open_login = (
|
|
1169
|
+
manual_login
|
|
1170
|
+
or not will_import
|
|
1171
|
+
or rolled_up_error_type == "keychain_acl_denied"
|
|
1172
|
+
)
|
|
1144
1173
|
|
|
1145
1174
|
shown = False
|
|
1146
1175
|
if open_login:
|
|
@@ -1156,7 +1185,7 @@ def cmd_connect(args) -> dict:
|
|
|
1156
1185
|
# That race is what made setup misreport the handle as "missing." If the
|
|
1157
1186
|
# cookie appears, fall through to the same connected/persist/handle path the
|
|
1158
1187
|
# auto-import success branch uses.
|
|
1159
|
-
login_wait = getattr(args, "login_wait",
|
|
1188
|
+
login_wait = getattr(args, "login_wait", 300.0)
|
|
1160
1189
|
if login_wait and login_wait > 0 and _poll_for_login(timeout=login_wait):
|
|
1161
1190
|
try:
|
|
1162
1191
|
if _is_session_valid():
|
|
@@ -1197,16 +1226,23 @@ def cmd_connect(args) -> dict:
|
|
|
1197
1226
|
)
|
|
1198
1227
|
extra["remediation"] = "rerun_connect_x_and_click_allow"
|
|
1199
1228
|
elif open_login:
|
|
1200
|
-
#
|
|
1201
|
-
#
|
|
1229
|
+
# Manual login: either the caller asked for it (--manual-login) or there
|
|
1230
|
+
# was no existing X session anywhere to import, so this is the only path.
|
|
1231
|
+
# A focused login window is open and we waited; they just haven't finished
|
|
1232
|
+
# signing in yet.
|
|
1233
|
+
_why = (
|
|
1234
|
+
"" if will_import
|
|
1235
|
+
else "No existing X session was found in your everyday browser to import, so "
|
|
1236
|
+
)
|
|
1237
|
+
_tried = (" (Auto-import tried: " + ", ".join(sources) + ".)") if will_import else ""
|
|
1202
1238
|
note = (
|
|
1203
|
-
|
|
1239
|
+
_why
|
|
1240
|
+
+ "a Chrome window for the autoposter is open at the X login page"
|
|
1204
1241
|
+ ("" if shown else " (if you don't see it, look for a 'Google Chrome' window)")
|
|
1205
1242
|
+ " and you are NOT logged in yet. Log in there yourself — username, password, "
|
|
1206
1243
|
"and 2FA if prompted — in that window. When your X home timeline shows, ask me "
|
|
1207
1244
|
"to confirm and I'll re-check (run connect_x again). The session is saved to the "
|
|
1208
|
-
"autoposter's own profile, so this is a one-time step.
|
|
1209
|
-
"(Auto-import tried: " + ", ".join(sources) + ".)"
|
|
1245
|
+
"autoposter's own profile, so this is a one-time step." + _tried
|
|
1210
1246
|
)
|
|
1211
1247
|
else:
|
|
1212
1248
|
# Auto-import failed for a non-deny reason and the user did NOT ask for
|
|
@@ -1294,10 +1330,12 @@ def main() -> int:
|
|
|
1294
1330
|
"the login window only opens when the user DENIED the keychain "
|
|
1295
1331
|
"prompt; every other auto-import failure returns needs_login "
|
|
1296
1332
|
"without popping an unexpected browser window.")
|
|
1297
|
-
c.add_argument("--login-wait", type=float, default=
|
|
1333
|
+
c.add_argument("--login-wait", type=float, default=300.0,
|
|
1298
1334
|
help="Seconds to wait for a MANUAL login to complete before "
|
|
1299
|
-
"returning needs_login (default
|
|
1300
|
-
"
|
|
1335
|
+
"returning needs_login (default 300; 0 disables the wait). "
|
|
1336
|
+
"A human finding the on-screen/VNC window plus password + 2FA "
|
|
1337
|
+
"routinely exceeds 90s, so the wait is generous. Prevents the "
|
|
1338
|
+
"detection race that misreports the handle as missing.")
|
|
1301
1339
|
args = ap.parse_args()
|
|
1302
1340
|
|
|
1303
1341
|
if args.cmd == "detect-sources":
|
package/scripts/snapshot.py
CHANGED
|
@@ -512,6 +512,26 @@ def _is_newer(latest, current) -> bool:
|
|
|
512
512
|
return _ver_key(latest) > _ver_key(current)
|
|
513
513
|
|
|
514
514
|
|
|
515
|
+
def version_status() -> dict:
|
|
516
|
+
"""Version/update fields ONLY, independent of the rest of compute(). compute()
|
|
517
|
+
calls _x_status() first, which can block up to 90s in the setup_twitter_auth.py
|
|
518
|
+
subprocess (CDP to Chrome) whenever a scan job is holding the browser busy —
|
|
519
|
+
that freezes update_available/latest_version on an otherwise-busy box even
|
|
520
|
+
though the GitHub check itself is a ~1s curl. Callers that want the "Update
|
|
521
|
+
available" banner to stay live regardless of X/runtime state should poll this
|
|
522
|
+
directly instead of waiting on the full compute()."""
|
|
523
|
+
installed = _resolve_version()
|
|
524
|
+
channel = _channel()
|
|
525
|
+
latest, latest_tag = _latest_published(channel)
|
|
526
|
+
return {
|
|
527
|
+
"version": installed,
|
|
528
|
+
"channel": channel,
|
|
529
|
+
"latest_version": latest,
|
|
530
|
+
"latest_tag": latest_tag,
|
|
531
|
+
"update_available": bool(latest) and _is_newer(latest, installed),
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
515
535
|
# ---- onboarding ledger + live overlay --------------------------------------
|
|
516
536
|
def _onboarding_live(live_status):
|
|
517
537
|
led = _read_json(os.path.join(_state_dir(), "onboarding-progress.json")) or {}
|