@m13v/s4l 1.6.197-rc.7
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/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
// uv-owned Python runtime provisioning for the social-autoposter MCP.
|
|
2
|
+
//
|
|
3
|
+
// The pipeline is ~60k lines of Python. New users don't have a usable Python
|
|
4
|
+
// env (missing / wrong version / externally-managed / Xcode CLT prompt), which
|
|
5
|
+
// is the #1 source of install failures. This module provisions a fully OWNED
|
|
6
|
+
// runtime that never touches the user's system Python or PATH:
|
|
7
|
+
//
|
|
8
|
+
// 1. uv (Astral's standalone Python launcher)
|
|
9
|
+
// 2. a standalone CPython via `uv python install` (NOT the user's python)
|
|
10
|
+
// 3. an owned venv at ~/.social-autoposter-mcp/runtime/.venv
|
|
11
|
+
// 4. the pipeline deps (requirements.txt) synced into that venv
|
|
12
|
+
// 5. the Playwright Chromium binary
|
|
13
|
+
//
|
|
14
|
+
// The absolute interpreter path is written to runtime.json; the server reads it
|
|
15
|
+
// for S4L_PYTHON. No PATH lookup, no venv activation, no system python — so the
|
|
16
|
+
// whole "Python environment + paths" class of bug disappears.
|
|
17
|
+
//
|
|
18
|
+
// Progress is written to install-progress.json as a JSON object the panel polls
|
|
19
|
+
// via the `install_status` tool (host-agnostic; survives the iframe sandbox).
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { captureError } from "./telemetry.js";
|
|
26
|
+
// Pin the standalone CPython series the venv is built from. Bump deliberately.
|
|
27
|
+
const PYTHON_VERSION = "3.12";
|
|
28
|
+
// The CDP scan engine the twitter cycle shells out to (~/.local/bin/browser-harness).
|
|
29
|
+
// The npm front door (bin/cli.js) clones + `uv tool install -e` this; a bare
|
|
30
|
+
// .mcpb install never did, so draft_cycle's scan found no engine and produced
|
|
31
|
+
// zero drafts. KEEP BROWSER_HARNESS_PIN IN SYNC WITH bin/cli.js (pinned so
|
|
32
|
+
// upstream drift can't reach users untested).
|
|
33
|
+
const BROWSER_HARNESS_PIN = "6d20866664ea3d9691b27bbf64f42ae097437dc3";
|
|
34
|
+
const BROWSER_HARNESS_REPO = "https://github.com/browser-use/browser-harness";
|
|
35
|
+
const HARNESS_DIR = path.join(os.homedir(), "Developer", "browser-harness");
|
|
36
|
+
const HARNESS_BIN = path.join(os.homedir(), ".local", "bin", "browser-harness");
|
|
37
|
+
// The harness drives a REAL Google Chrome over CDP (see twitter-backend.sh
|
|
38
|
+
// _resolve_chrome_bin). Nothing installs Chrome, the runtime only ever
|
|
39
|
+
// downloaded Playwright's Chromium (which the cycle does NOT use), so a .mcpb
|
|
40
|
+
// install on a Chrome-less Mac green-lit every step and then died mid-cycle with
|
|
41
|
+
// "no Chrome/Chromium binary found." These are the paths twitter-backend.sh
|
|
42
|
+
// probes (plus ~/Applications, the no-sudo fallback target we install into).
|
|
43
|
+
const GOOGLE_CHROME_DMG = "https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg";
|
|
44
|
+
const CHROME_CANDIDATES = [
|
|
45
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
46
|
+
path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
|
|
47
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
48
|
+
"/usr/bin/google-chrome",
|
|
49
|
+
"/usr/bin/google-chrome-stable",
|
|
50
|
+
"/usr/bin/chromium",
|
|
51
|
+
"/usr/bin/chromium-browser",
|
|
52
|
+
"/snap/bin/chromium",
|
|
53
|
+
];
|
|
54
|
+
// dist/runtime.js -> repo root is two levels up (mcp/dist -> mcp -> repo root).
|
|
55
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
// Everything we own lives under one state dir (next to setup-state.json).
|
|
57
|
+
const STATE_DIR = process.env.S4L_STATE_DIR || process.env.SAPS_STATE_DIR || path.join(os.homedir(), ".social-autoposter-mcp");
|
|
58
|
+
const RUNTIME_DIR = path.join(STATE_DIR, "runtime");
|
|
59
|
+
const VENV_DIR = path.join(RUNTIME_DIR, ".venv");
|
|
60
|
+
const RUNTIME_JSON = path.join(STATE_DIR, "runtime.json");
|
|
61
|
+
const PROGRESS_JSON = path.join(STATE_DIR, "install-progress.json");
|
|
62
|
+
// The venv's interpreter, by absolute path (no activation needed).
|
|
63
|
+
const VENV_PYTHON = process.platform === "win32"
|
|
64
|
+
? path.join(VENV_DIR, "Scripts", "python.exe")
|
|
65
|
+
: path.join(VENV_DIR, "bin", "python3");
|
|
66
|
+
// Where the pipeline source is materialized for a bare .mcpb install (no clone).
|
|
67
|
+
// The embedded tarball is the EXACT `npm pack` output (same `files` allowlist as
|
|
68
|
+
// the published package), so the unpacked source is byte-identical to what npm
|
|
69
|
+
// users run (no second curation list, no drift). npm tarballs unpack under a
|
|
70
|
+
// top-level `package/` dir, so the repo root is REPO_MATERIALIZED/package.
|
|
71
|
+
const REPO_MATERIALIZE_DIR = path.join(STATE_DIR, "repo");
|
|
72
|
+
const MATERIALIZED_REPO = path.join(REPO_MATERIALIZE_DIR, "package");
|
|
73
|
+
// dist/runtime.js sits beside the embedded tarball produced at build time.
|
|
74
|
+
const EMBEDDED_TARBALL = path.join(__dirname, "pipeline.tgz");
|
|
75
|
+
// ---- menu bar app (macOS status-bar mini-dashboard) ------------------------
|
|
76
|
+
// Provisioned as install Step 8 and re-ensured at server boot. The rumps app
|
|
77
|
+
// runs from the owned venv as a KeepAlive LaunchAgent, pointed at a STABLE copy
|
|
78
|
+
// under the state dir (NOT the extension dir, which is replaced on every
|
|
79
|
+
// update). KEEP MENUBAR_LABEL in sync with menubar/s4l_state.py and
|
|
80
|
+
// scripts/reset-test-machine.sh.
|
|
81
|
+
export const MENUBAR_LABEL = "com.m13v.social-autoposter.menubar";
|
|
82
|
+
export const MENUBAR_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${MENUBAR_LABEL}.plist`);
|
|
83
|
+
const MENUBAR_DIR = path.join(STATE_DIR, "menubar");
|
|
84
|
+
const MENUBAR_ENTRY = path.join(MENUBAR_DIR, "s4l_menubar.py");
|
|
85
|
+
const MENUBAR_OUT_LOG = path.join(MENUBAR_DIR, "menubar.out.log");
|
|
86
|
+
const MENUBAR_ERR_LOG = path.join(MENUBAR_DIR, "menubar.err.log");
|
|
87
|
+
// Stop sentinel: the menu bar's Quit flow writes this file (and boots itself
|
|
88
|
+
// out) to record that the USER explicitly stopped S4L. Every auto-start path
|
|
89
|
+
// (boot-time ensureMenubar, runtime provision) must respect it, otherwise the
|
|
90
|
+
// tray is guaranteed back on the next Claude restart — the exact bug users hit
|
|
91
|
+
// after Quit. Only an explicit start action (restart_menubar tool, queue_setup
|
|
92
|
+
// re-arm) clears it. KEEP the filename in sync with menubar/s4l_menubar.py.
|
|
93
|
+
const MENUBAR_STOP_FLAG = path.join(STATE_DIR, "stopped.flag");
|
|
94
|
+
export function menubarStopped() {
|
|
95
|
+
try {
|
|
96
|
+
return fs.existsSync(MENUBAR_STOP_FLAG);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function clearMenubarStop() {
|
|
103
|
+
try {
|
|
104
|
+
fs.rmSync(MENUBAR_STOP_FLAG, { force: true });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* best-effort */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// A directory is a usable pipeline clone only if it carries requirements.txt
|
|
111
|
+
// (the deps manifest) AND scripts/ (the pipeline). Guards against pointing at an
|
|
112
|
+
// empty extension dir or a half-deleted state dir.
|
|
113
|
+
function looksLikeRepo(dir) {
|
|
114
|
+
if (!dir)
|
|
115
|
+
return false;
|
|
116
|
+
return (fs.existsSync(path.join(dir, "requirements.txt")) &&
|
|
117
|
+
fs.existsSync(path.join(dir, "scripts")));
|
|
118
|
+
}
|
|
119
|
+
// ---- Stray git-checkout detection -------------------------------------------
|
|
120
|
+
// A pipeline repo that is a git checkout is legitimate ONLY when it is the
|
|
121
|
+
// working tree the running server itself was built in (dev registration:
|
|
122
|
+
// `node <checkout>/mcp/dist/index.js`). Any other .git-bearing repo on a
|
|
123
|
+
// shipped install is a "stray" clone (someone git-cloned the public repo during
|
|
124
|
+
// troubleshooting): every self-update lane deliberately refuses to touch a
|
|
125
|
+
// checkout, so the box silently freezes at the clone's version forever while
|
|
126
|
+
// the menu bar keeps re-showing the update banner. We only reject a checkout when there is
|
|
127
|
+
// something to serve instead (a materialized repo, or the embedded tarball to
|
|
128
|
+
// make one); otherwise legacy behavior is preserved.
|
|
129
|
+
function hasGit(dir) {
|
|
130
|
+
try {
|
|
131
|
+
return fs.existsSync(path.join(dir, ".git"));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function isDevCheckout(dir) {
|
|
138
|
+
try {
|
|
139
|
+
return path.resolve(__dirname, "..", "..") === path.resolve(dir);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function isStrayCheckout(dir) {
|
|
146
|
+
if (!dir)
|
|
147
|
+
return false;
|
|
148
|
+
if (!hasGit(dir) || isDevCheckout(dir))
|
|
149
|
+
return false;
|
|
150
|
+
return looksLikeRepo(MATERIALIZED_REPO) || fs.existsSync(EMBEDDED_TARBALL);
|
|
151
|
+
}
|
|
152
|
+
// Resolve the pipeline repo the server shells out to, preferring (in order):
|
|
153
|
+
// 1. S4L_REPO_DIR when it's a real clone (npm/git install, Story A); never
|
|
154
|
+
// overwritten, power users keep their working tree.
|
|
155
|
+
// 2. runtime.json's repo_dir (the materialized repo from a .mcpb install).
|
|
156
|
+
// 3. the materialized path on disk even if runtime.json is missing.
|
|
157
|
+
// 4. S4L_REPO_DIR as-is, then the two-levels-up dev default.
|
|
158
|
+
// Dynamic (not a load-time const) so a first-run materialize is picked up
|
|
159
|
+
// without a server restart (same property resolvePython() relies on).
|
|
160
|
+
// Stray git checkouts (see isStrayCheckout) are skipped at every step: a clone
|
|
161
|
+
// the running server does NOT live in can never be the pipeline repo on a
|
|
162
|
+
// shipped install, because no update lane will ever advance it.
|
|
163
|
+
export function resolveRepoDir() {
|
|
164
|
+
const env = (process.env.S4L_REPO_DIR ?? process.env.SAPS_REPO_DIR);
|
|
165
|
+
if (looksLikeRepo(env) && !isStrayCheckout(env))
|
|
166
|
+
return env;
|
|
167
|
+
const rt = readRuntime();
|
|
168
|
+
if (rt && rt.repo_dir && looksLikeRepo(rt.repo_dir) && !isStrayCheckout(rt.repo_dir))
|
|
169
|
+
return rt.repo_dir;
|
|
170
|
+
if (looksLikeRepo(MATERIALIZED_REPO))
|
|
171
|
+
return MATERIALIZED_REPO;
|
|
172
|
+
if (env)
|
|
173
|
+
return env;
|
|
174
|
+
return path.resolve(__dirname, "..", "..");
|
|
175
|
+
}
|
|
176
|
+
const STEP_DEFS = [
|
|
177
|
+
{ id: "repo", label: "Unpack pipeline source" },
|
|
178
|
+
{ id: "uv", label: "Install uv (Python launcher)" },
|
|
179
|
+
{ id: "python", label: `Download standalone Python ${PYTHON_VERSION}` },
|
|
180
|
+
{ id: "venv", label: "Create owned virtual environment" },
|
|
181
|
+
{ id: "deps", label: "Install pipeline dependencies" },
|
|
182
|
+
{ id: "chromium", label: "Download Chromium browser (~150MB)" },
|
|
183
|
+
{ id: "harness", label: "Install browser-harness (CDP scan engine)" },
|
|
184
|
+
{ id: "chrome", label: "Install Google Chrome (browser the scanner drives)" },
|
|
185
|
+
{ id: "menubar", label: "Install menu bar app" },
|
|
186
|
+
];
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// runtime.json (the durable result the server reads for S4L_PYTHON).
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
export function readRuntime() {
|
|
191
|
+
try {
|
|
192
|
+
if (!fs.existsSync(RUNTIME_JSON))
|
|
193
|
+
return null;
|
|
194
|
+
return JSON.parse(fs.readFileSync(RUNTIME_JSON, "utf-8"));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// The .mcpb version this running server was built from. The release script
|
|
201
|
+
// stamps dist/version.json next to this compiled module. Returns null for a dev
|
|
202
|
+
// build with no stamp (in which case we leave the working tree untouched).
|
|
203
|
+
const VERSION_JSON = path.join(__dirname, "version.json");
|
|
204
|
+
function bundledVersion() {
|
|
205
|
+
try {
|
|
206
|
+
const v = JSON.parse(fs.readFileSync(VERSION_JSON, "utf-8"));
|
|
207
|
+
return typeof v?.version === "string" ? v.version : null;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Re-materialize the pipeline source when a plugin UPDATE shipped a newer
|
|
214
|
+
// pipeline.tgz than what's on disk. The .mcpb update refreshes dist/ (this
|
|
215
|
+
// server) but does NOT re-extract the embedded tarball, so without this the box
|
|
216
|
+
// keeps running the pipeline it first materialized and server/pipeline fixes
|
|
217
|
+
// silently never take effect on update. Cheap: a version compare, and a tar only
|
|
218
|
+
// when stale.
|
|
219
|
+
//
|
|
220
|
+
// Unlike provision()'s Step 0, this does NOT wipe the repo first — it extracts
|
|
221
|
+
// OVER it, so the project's config.json and the logs/ dir (neither of which is in
|
|
222
|
+
// the tarball) survive. Best-effort and synchronous: meant to run at server
|
|
223
|
+
// startup BEFORE any tool shells out to the pipeline; never throws.
|
|
224
|
+
export function ensurePipelineCurrent() {
|
|
225
|
+
try {
|
|
226
|
+
// A real clone (npm/git install) is the user's working tree — never touch
|
|
227
|
+
// it. A STRAY checkout (git clone nobody opted into, see isStrayCheckout)
|
|
228
|
+
// does not qualify: fall through so healStrayCheckout() can reclaim.
|
|
229
|
+
const env = (process.env.S4L_REPO_DIR ?? process.env.SAPS_REPO_DIR);
|
|
230
|
+
if (looksLikeRepo(env) && !isStrayCheckout(env))
|
|
231
|
+
return;
|
|
232
|
+
healStrayCheckout();
|
|
233
|
+
// Nothing materialized yet, or no tarball to extract from: provision() owns
|
|
234
|
+
// the first materialize; this only refreshes an existing one.
|
|
235
|
+
if (!looksLikeRepo(MATERIALIZED_REPO))
|
|
236
|
+
return;
|
|
237
|
+
if (!fs.existsSync(EMBEDDED_TARBALL))
|
|
238
|
+
return;
|
|
239
|
+
const bundled = bundledVersion();
|
|
240
|
+
if (!bundled)
|
|
241
|
+
return; // dev build, no stamp — leave the materialized repo alone.
|
|
242
|
+
const rt = readRuntime();
|
|
243
|
+
if (rt?.pipeline_version === bundled)
|
|
244
|
+
return; // already current.
|
|
245
|
+
const prevVer = rt?.pipeline_version ?? "unrecorded"; // capture before we mutate rt below.
|
|
246
|
+
// Stale (or never recorded): extract the new pipeline OVER the materialized
|
|
247
|
+
// repo. No rmSync, so config.json + logs are preserved.
|
|
248
|
+
fs.mkdirSync(REPO_MATERIALIZE_DIR, { recursive: true });
|
|
249
|
+
const r = spawnSync("tar", ["xzf", EMBEDDED_TARBALL, "-C", REPO_MATERIALIZE_DIR], {
|
|
250
|
+
timeout: 120000,
|
|
251
|
+
});
|
|
252
|
+
if (r.status !== 0 || !looksLikeRepo(MATERIALIZED_REPO)) {
|
|
253
|
+
console.error(`[runtime] pipeline re-materialize failed (exit ${r.status}); keeping existing pipeline`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Record the new version so we don't re-extract on every boot.
|
|
257
|
+
const next = rt ?? readRuntime();
|
|
258
|
+
if (next) {
|
|
259
|
+
next.pipeline_version = bundled;
|
|
260
|
+
try {
|
|
261
|
+
fs.writeFileSync(RUNTIME_JSON, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* best effort — worst case we re-extract next boot */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
console.error(`[runtime] re-materialized pipeline -> ${bundled} (was ${prevVer})`);
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
console.error(`[runtime] ensurePipelineCurrent error: ${e?.message || e}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Reclaim a box whose pipeline repo resolution landed on a stray git checkout
|
|
274
|
+
// (either S4L_REPO_DIR baked into an old registration/plist, or runtime.json's
|
|
275
|
+
// repo_dir). Non-destructive: the checkout stays on disk untouched; we simply
|
|
276
|
+
// stop using it. Steps: materialize the bundled pipeline if it isn't on disk
|
|
277
|
+
// yet, migrate the user state the checkout accumulated while it was live
|
|
278
|
+
// (config.json, .env; the project setup lives there), and re-point
|
|
279
|
+
// runtime.json so every later resolveRepoDir()/plist rewrite agrees. Runs at
|
|
280
|
+
// server boot from ensurePipelineCurrent(); best-effort, never throws.
|
|
281
|
+
function healStrayCheckout() {
|
|
282
|
+
try {
|
|
283
|
+
const env = (process.env.S4L_REPO_DIR ?? process.env.SAPS_REPO_DIR);
|
|
284
|
+
const rt = readRuntime();
|
|
285
|
+
const stray = (looksLikeRepo(env) && isStrayCheckout(env) && env) ||
|
|
286
|
+
(rt?.repo_dir && looksLikeRepo(rt.repo_dir) && isStrayCheckout(rt.repo_dir) && rt.repo_dir) ||
|
|
287
|
+
null;
|
|
288
|
+
if (!stray)
|
|
289
|
+
return;
|
|
290
|
+
if (!looksLikeRepo(MATERIALIZED_REPO)) {
|
|
291
|
+
if (!fs.existsSync(EMBEDDED_TARBALL))
|
|
292
|
+
return; // nothing to serve instead
|
|
293
|
+
fs.mkdirSync(REPO_MATERIALIZE_DIR, { recursive: true });
|
|
294
|
+
const r = spawnSync("tar", ["xzf", EMBEDDED_TARBALL, "-C", REPO_MATERIALIZE_DIR], {
|
|
295
|
+
timeout: 120000,
|
|
296
|
+
});
|
|
297
|
+
if (r.status !== 0 || !looksLikeRepo(MATERIALIZED_REPO)) {
|
|
298
|
+
console.error(`[runtime] stray-checkout heal: materialize failed (exit ${r.status}); keeping ${stray}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const f of ["config.json", ".env"]) {
|
|
303
|
+
try {
|
|
304
|
+
const src = path.join(stray, f);
|
|
305
|
+
const dst = path.join(MATERIALIZED_REPO, f);
|
|
306
|
+
if (!fs.existsSync(src))
|
|
307
|
+
continue;
|
|
308
|
+
if (!fs.existsSync(dst) || fs.statSync(src).mtimeMs > fs.statSync(dst).mtimeMs) {
|
|
309
|
+
fs.copyFileSync(src, dst);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* per-file best effort */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (rt && rt.repo_dir !== MATERIALIZED_REPO) {
|
|
317
|
+
rt.repo_dir = MATERIALIZED_REPO;
|
|
318
|
+
try {
|
|
319
|
+
fs.writeFileSync(RUNTIME_JSON, JSON.stringify(rt, null, 2) + "\n", "utf-8");
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* best effort; resolveRepoDir falls back to MATERIALIZED_REPO anyway */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
console.error(`[runtime] pipeline repo was a stray git checkout (${stray}); re-pointed to ` +
|
|
326
|
+
`${MATERIALIZED_REPO}. The checkout was left on disk but is no longer used.`);
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
console.error(`[runtime] healStrayCheckout error: ${e?.message || e}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// The runtime is "ready" only if runtime.json says so, the interpreter it
|
|
333
|
+
// points at still exists on disk (catches a half-deleted state dir), AND a
|
|
334
|
+
// usable pipeline repo resolves. The repo check catches a pre-Story-B runtime
|
|
335
|
+
// that was marked ready before Step 0 ("Unpack pipeline source") existed: the
|
|
336
|
+
// venv is present so the old check passed, but the embedded tarball was never
|
|
337
|
+
// materialized, so the server shells out to an empty repo and the panel reads a
|
|
338
|
+
// blank "0/1, not set up" world. Returning false here forces install_runtime to
|
|
339
|
+
// re-provision, which runs Step 0 and materializes the repo (idempotent).
|
|
340
|
+
export function runtimeReady() {
|
|
341
|
+
const rt = readRuntime();
|
|
342
|
+
if (!(rt && rt.ready && rt.python && fs.existsSync(rt.python)))
|
|
343
|
+
return false;
|
|
344
|
+
return looksLikeRepo(resolveRepoDir());
|
|
345
|
+
}
|
|
346
|
+
// Resolve the interpreter the pipeline should run under, preferring the owned
|
|
347
|
+
// uv runtime, then the install-pinned S4L_PYTHON, then bare python3. This is
|
|
348
|
+
// the single seam that moves the pipeline off the user's system Python.
|
|
349
|
+
export function resolvePython() {
|
|
350
|
+
const rt = readRuntime();
|
|
351
|
+
if (rt && rt.python && fs.existsSync(rt.python))
|
|
352
|
+
return rt.python;
|
|
353
|
+
return process.env.S4L_PYTHON || process.env.SAPS_PYTHON || "python3";
|
|
354
|
+
}
|
|
355
|
+
// First Chrome/Chromium binary that exists AND is executable, from the same
|
|
356
|
+
// paths twitter-backend.sh probes (plus ~/Applications). Returns null when none
|
|
357
|
+
// is on disk (the cycle's own PATH-based resolver may still find one).
|
|
358
|
+
function detectChromeBin() {
|
|
359
|
+
const cands = [process.env.BH_CHROME_BIN, ...CHROME_CANDIDATES];
|
|
360
|
+
for (const c of cands) {
|
|
361
|
+
if (!c)
|
|
362
|
+
continue;
|
|
363
|
+
try {
|
|
364
|
+
fs.accessSync(c, fs.constants.X_OK);
|
|
365
|
+
return c;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
/* not present / not executable; try next */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
// Resolve the Chrome binary the cycle should drive: the provisioned path from
|
|
374
|
+
// runtime.json first (catches our ~/Applications fallback install, which the
|
|
375
|
+
// shell's _resolve_chrome_bin doesn't scan), then live detection, then the env
|
|
376
|
+
// override. null means "let the shell resolve it from PATH."
|
|
377
|
+
export function resolveChrome() {
|
|
378
|
+
const rt = readRuntime();
|
|
379
|
+
if (rt && rt.chrome) {
|
|
380
|
+
try {
|
|
381
|
+
fs.accessSync(rt.chrome, fs.constants.X_OK);
|
|
382
|
+
return rt.chrome;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* recorded path went away; fall through to live detection */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return detectChromeBin() || process.env.BH_CHROME_BIN || null;
|
|
389
|
+
}
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// install-progress.json (polled by the panel via install_status).
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
export function readProgress() {
|
|
394
|
+
try {
|
|
395
|
+
if (!fs.existsSync(PROGRESS_JSON))
|
|
396
|
+
return null;
|
|
397
|
+
return JSON.parse(fs.readFileSync(PROGRESS_JSON, "utf-8"));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function writeProgress(p) {
|
|
404
|
+
p.updated_at = new Date().toISOString();
|
|
405
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
406
|
+
fs.writeFileSync(PROGRESS_JSON, JSON.stringify(p, null, 2) + "\n", "utf-8");
|
|
407
|
+
}
|
|
408
|
+
function freshProgress() {
|
|
409
|
+
const now = new Date().toISOString();
|
|
410
|
+
return {
|
|
411
|
+
running: true,
|
|
412
|
+
done: false,
|
|
413
|
+
ok: false,
|
|
414
|
+
steps: STEP_DEFS.map((s) => ({ id: s.id, label: s.label, status: "pending" })),
|
|
415
|
+
started_at: now,
|
|
416
|
+
updated_at: now,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Spawning helper. Captures output; never throws (returns code + tail).
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
function sh(cmd, args, opts = {}) {
|
|
423
|
+
return new Promise((resolve) => {
|
|
424
|
+
const child = spawn(cmd, args, {
|
|
425
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
426
|
+
});
|
|
427
|
+
let out = "";
|
|
428
|
+
const cap = (d) => {
|
|
429
|
+
out += d.toString();
|
|
430
|
+
if (out.length > 20000)
|
|
431
|
+
out = out.slice(-20000); // keep a tail, bound memory
|
|
432
|
+
};
|
|
433
|
+
let timer;
|
|
434
|
+
if (opts.timeoutMs) {
|
|
435
|
+
timer = setTimeout(() => child.kill("SIGTERM"), opts.timeoutMs);
|
|
436
|
+
}
|
|
437
|
+
child.stdout?.on("data", cap);
|
|
438
|
+
child.stderr?.on("data", cap);
|
|
439
|
+
child.on("close", (code) => {
|
|
440
|
+
if (timer)
|
|
441
|
+
clearTimeout(timer);
|
|
442
|
+
resolve({ code: code ?? -1, out });
|
|
443
|
+
});
|
|
444
|
+
child.on("error", (err) => {
|
|
445
|
+
if (timer)
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
resolve({ code: -1, out: out + String(err) });
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
function bash(script, timeoutMs) {
|
|
452
|
+
return sh("bash", ["-lc", script], { timeoutMs });
|
|
453
|
+
}
|
|
454
|
+
// Locate uv (Astral installs to ~/.local/bin/uv; Homebrew to /opt/homebrew/bin).
|
|
455
|
+
function findUv() {
|
|
456
|
+
const candidates = [
|
|
457
|
+
path.join(os.homedir(), ".local", "bin", "uv"),
|
|
458
|
+
"/opt/homebrew/bin/uv",
|
|
459
|
+
"/usr/local/bin/uv",
|
|
460
|
+
"/usr/bin/uv",
|
|
461
|
+
];
|
|
462
|
+
for (const c of candidates) {
|
|
463
|
+
if (fs.existsSync(c))
|
|
464
|
+
return c;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Provisioning. Idempotent: re-running re-derives any missing piece. A module
|
|
470
|
+
// guard prevents two concurrent runs within the same process.
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
let inFlight = null;
|
|
473
|
+
export function isProvisioning() {
|
|
474
|
+
return inFlight !== null;
|
|
475
|
+
}
|
|
476
|
+
// Kick off provisioning (or return the in-flight run). Returns immediately with
|
|
477
|
+
// the initial progress snapshot; the panel polls install_status for updates.
|
|
478
|
+
export function startProvisioning() {
|
|
479
|
+
if (!inFlight) {
|
|
480
|
+
const progress = freshProgress();
|
|
481
|
+
writeProgress(progress);
|
|
482
|
+
inFlight = provision(progress).finally(() => {
|
|
483
|
+
inFlight = null;
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return readProgress() ?? freshProgress();
|
|
487
|
+
}
|
|
488
|
+
// Boot-time deterministic provisioning: bring the owned runtime to ready on
|
|
489
|
+
// every server start WITHOUT relying on the agent to call `runtime
|
|
490
|
+
// action:'install'`. Called from main() on every server start, which the host
|
|
491
|
+
// spawns when the plugin loads — so the env starts installing the moment the
|
|
492
|
+
// plugin is active, before any agent turn.
|
|
493
|
+
//
|
|
494
|
+
// Provision-on-boot policy (option a): auto-fire whenever the runtime is not
|
|
495
|
+
// ready, fresh install or interrupted one alike. A brand-new install downloads
|
|
496
|
+
// and installs everything it needs (uv, Python, venv, deps, Chromium, harness,
|
|
497
|
+
// Chrome — whatever the megabytes) up front; an install that died mid-way (a
|
|
498
|
+
// failed step, or Claude restarted between steps) resumes. provision() is
|
|
499
|
+
// idempotent, so this re-checks done steps and skips them, attempting only
|
|
500
|
+
// what's missing. The single deterministic trigger is server boot, not agent
|
|
501
|
+
// reasoning. Returns true if it kicked a run. Best-effort, never throws.
|
|
502
|
+
export function ensureRuntimeProvisioned() {
|
|
503
|
+
try {
|
|
504
|
+
if (runtimeReady())
|
|
505
|
+
return false; // fully provisioned already
|
|
506
|
+
if (isProvisioning())
|
|
507
|
+
return false; // a run is in flight in this process
|
|
508
|
+
// Not ready: provision everything now (startProvisioning is idempotent and
|
|
509
|
+
// re-entrant — a no-op if a run is already in flight).
|
|
510
|
+
startProvisioning();
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return false; // best-effort; a boot provision must never break startup
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function provision(progress) {
|
|
518
|
+
const setStep = (id, status, detail) => {
|
|
519
|
+
const st = progress.steps.find((s) => s.id === id);
|
|
520
|
+
if (st) {
|
|
521
|
+
st.status = status;
|
|
522
|
+
if (detail !== undefined)
|
|
523
|
+
st.detail = detail;
|
|
524
|
+
}
|
|
525
|
+
writeProgress(progress);
|
|
526
|
+
};
|
|
527
|
+
const fail = (msg) => {
|
|
528
|
+
progress.running = false;
|
|
529
|
+
progress.done = true;
|
|
530
|
+
progress.ok = false;
|
|
531
|
+
progress.error = msg;
|
|
532
|
+
writeProgress(progress);
|
|
533
|
+
// Every fatal install-step failure (repo unpack, uv, python, venv, deps,
|
|
534
|
+
// chromium, harness, chrome) was previously only written to the local
|
|
535
|
+
// install-progress.json, invisible to us. Report it so a failed runtime
|
|
536
|
+
// install becomes a real Sentry event, tagged with the step that failed.
|
|
537
|
+
const failedStep = progress.steps.find((s) => s.status === "running");
|
|
538
|
+
captureError(new Error(msg), {
|
|
539
|
+
component: "install",
|
|
540
|
+
...(failedStep ? { step: failedStep.id } : {}),
|
|
541
|
+
});
|
|
542
|
+
return progress;
|
|
543
|
+
};
|
|
544
|
+
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
545
|
+
// --- Step 0: materialize the pipeline repo --------------------------------
|
|
546
|
+
// If S4L_REPO_DIR is already a real clone (npm/git install), use it untouched.
|
|
547
|
+
// Otherwise (bare .mcpb double-click) unpack the embedded npm tarball so the
|
|
548
|
+
// pipeline source lands on disk and every later step + the server agree on one
|
|
549
|
+
// repo path. requirements.txt MUST exist after this for the deps step.
|
|
550
|
+
setStep("repo", "running");
|
|
551
|
+
let resolvedRepo;
|
|
552
|
+
if (looksLikeRepo((process.env.S4L_REPO_DIR ?? process.env.SAPS_REPO_DIR)) &&
|
|
553
|
+
!isStrayCheckout((process.env.S4L_REPO_DIR ?? process.env.SAPS_REPO_DIR))) {
|
|
554
|
+
resolvedRepo = (process.env.S4L_REPO_DIR ?? process.env.SAPS_REPO_DIR);
|
|
555
|
+
setStep("repo", "done", `using existing clone: ${resolvedRepo}`);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
if (!fs.existsSync(EMBEDDED_TARBALL)) {
|
|
559
|
+
return fail(`no pipeline source: S4L_REPO_DIR is not a clone and the embedded ` +
|
|
560
|
+
`tarball is missing (${EMBEDDED_TARBALL}). Reinstall the extension or ` +
|
|
561
|
+
`set S4L_REPO_DIR to a social-autoposter clone.`);
|
|
562
|
+
}
|
|
563
|
+
// Clean any half-unpacked previous attempt, then extract fresh (idempotent).
|
|
564
|
+
try {
|
|
565
|
+
fs.rmSync(MATERIALIZED_REPO, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* best effort */
|
|
569
|
+
}
|
|
570
|
+
fs.mkdirSync(REPO_MATERIALIZE_DIR, { recursive: true });
|
|
571
|
+
const r = await sh("tar", ["xzf", EMBEDDED_TARBALL, "-C", REPO_MATERIALIZE_DIR], {
|
|
572
|
+
timeoutMs: 120000,
|
|
573
|
+
});
|
|
574
|
+
if (r.code !== 0 || !looksLikeRepo(MATERIALIZED_REPO)) {
|
|
575
|
+
return fail(`unpacking pipeline source failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
576
|
+
}
|
|
577
|
+
resolvedRepo = MATERIALIZED_REPO;
|
|
578
|
+
// Compatibility symlink: the pipeline scripts (run-twitter-cycle.sh and ~40
|
|
579
|
+
// siblings) hardcode $HOME/social-autoposter for REPO_DIR. A bare .mcpb
|
|
580
|
+
// materializes the repo under the state dir, so those paths don't resolve.
|
|
581
|
+
// Plant ~/social-autoposter -> materialized repo so every hardcoded
|
|
582
|
+
// reference resolves at once. Only when the path is entirely free: never
|
|
583
|
+
// clobber a real npm/git clone or any pre-existing entry (lstat catches
|
|
584
|
+
// dirs, files, and dangling symlinks; existsSync would miss a dangling one).
|
|
585
|
+
try {
|
|
586
|
+
const compat = path.join(os.homedir(), "social-autoposter");
|
|
587
|
+
let occupied = true;
|
|
588
|
+
try {
|
|
589
|
+
fs.lstatSync(compat);
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
occupied = false;
|
|
593
|
+
}
|
|
594
|
+
if (!occupied)
|
|
595
|
+
fs.symlinkSync(MATERIALIZED_REPO, compat);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
/* best effort; S4L_REPO_DIR + the run-*.sh fallback also resolve the repo */
|
|
599
|
+
}
|
|
600
|
+
setStep("repo", "done", `unpacked to ${resolvedRepo}`);
|
|
601
|
+
}
|
|
602
|
+
// --- Step 1: uv -----------------------------------------------------------
|
|
603
|
+
setStep("uv", "running");
|
|
604
|
+
let uv = findUv();
|
|
605
|
+
if (!uv) {
|
|
606
|
+
const r = await bash("curl -LsSf https://astral.sh/uv/install.sh | sh", 180000);
|
|
607
|
+
uv = findUv();
|
|
608
|
+
if (!uv) {
|
|
609
|
+
return fail(`uv install failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
setStep("uv", "done", uv);
|
|
613
|
+
// Pin uv's cache/data inside our state dir so the standalone Python and the
|
|
614
|
+
// venv resolve consistently and don't depend on ambient UV_* env.
|
|
615
|
+
const uvEnv = {
|
|
616
|
+
UV_PYTHON_INSTALL_DIR: path.join(RUNTIME_DIR, "python"),
|
|
617
|
+
};
|
|
618
|
+
// --- Step 2: standalone CPython ------------------------------------------
|
|
619
|
+
setStep("python", "running");
|
|
620
|
+
{
|
|
621
|
+
const r = await sh(uv, ["python", "install", PYTHON_VERSION], {
|
|
622
|
+
env: uvEnv,
|
|
623
|
+
timeoutMs: 300000,
|
|
624
|
+
});
|
|
625
|
+
if (r.code !== 0) {
|
|
626
|
+
return fail(`uv python install ${PYTHON_VERSION} failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
setStep("python", "done");
|
|
630
|
+
// --- Step 3: owned venv ---------------------------------------------------
|
|
631
|
+
setStep("venv", "running");
|
|
632
|
+
{
|
|
633
|
+
const r = await sh(uv, ["venv", "--python", PYTHON_VERSION, VENV_DIR], {
|
|
634
|
+
env: uvEnv,
|
|
635
|
+
timeoutMs: 120000,
|
|
636
|
+
});
|
|
637
|
+
if (r.code !== 0 || !fs.existsSync(VENV_PYTHON)) {
|
|
638
|
+
return fail(`uv venv failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
setStep("venv", "done", VENV_PYTHON);
|
|
642
|
+
// --- Step 4: pipeline deps ------------------------------------------------
|
|
643
|
+
setStep("deps", "running");
|
|
644
|
+
{
|
|
645
|
+
const reqPath = path.join(resolvedRepo, "requirements.txt");
|
|
646
|
+
const args = fs.existsSync(reqPath)
|
|
647
|
+
? ["pip", "install", "--python", VENV_PYTHON, "-r", reqPath]
|
|
648
|
+
: ["pip", "install", "--python", VENV_PYTHON, "playwright", "websocket-client", "cryptography"];
|
|
649
|
+
const r = await sh(uv, args, { env: uvEnv, timeoutMs: 600000 });
|
|
650
|
+
if (r.code !== 0) {
|
|
651
|
+
return fail(`dependency install failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
setStep("deps", "done");
|
|
655
|
+
// --- Step 5: Playwright Chromium -----------------------------------------
|
|
656
|
+
setStep("chromium", "running");
|
|
657
|
+
{
|
|
658
|
+
const r = await sh(VENV_PYTHON, ["-m", "playwright", "install", "chromium"], {
|
|
659
|
+
timeoutMs: 600000,
|
|
660
|
+
});
|
|
661
|
+
if (r.code !== 0) {
|
|
662
|
+
return fail(`playwright install chromium failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
663
|
+
}
|
|
664
|
+
// Smoke-test the EXACT gate the pipeline's post path runs at use time
|
|
665
|
+
// (twitter_post_plan.py preflight): the owned interpreter must import
|
|
666
|
+
// playwright. The reply step is the only Playwright importer, so a deps
|
|
667
|
+
// sync that left it unimportable was invisible until the first real post
|
|
668
|
+
// died with no_reply_json in production (Karol, 2026-06-22). Fail the
|
|
669
|
+
// install LOUDLY here instead.
|
|
670
|
+
const smoke = await sh(VENV_PYTHON, ["-c", "import playwright"], {
|
|
671
|
+
timeoutMs: 60000,
|
|
672
|
+
});
|
|
673
|
+
if (smoke.code !== 0) {
|
|
674
|
+
return fail(`runtime smoke test failed: ${VENV_PYTHON} cannot import playwright ` +
|
|
675
|
+
`(exit ${smoke.code}). ${smoke.out.slice(-400)}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
setStep("chromium", "done");
|
|
679
|
+
// --- Step 6: browser-harness CLI -----------------------------------------
|
|
680
|
+
// The twitter cycle (run-twitter-cycle.sh) drives Chrome over CDP by shelling
|
|
681
|
+
// out to ~/.local/bin/browser-harness. The npm installer (bin/cli.js) clones
|
|
682
|
+
// browser-use/browser-harness and `uv tool install -e`s it; a bare .mcpb
|
|
683
|
+
// install never provisioned it, so the scan engine was missing and every
|
|
684
|
+
// draft_cycle returned "no candidates". This brings .mcpb to parity with npm.
|
|
685
|
+
setStep("harness", "running");
|
|
686
|
+
{
|
|
687
|
+
// Clone if absent (mkdir parent first), else reuse the checkout.
|
|
688
|
+
if (!fs.existsSync(HARNESS_DIR)) {
|
|
689
|
+
fs.mkdirSync(path.dirname(HARNESS_DIR), { recursive: true });
|
|
690
|
+
const clone = await sh("git", ["clone", "--depth", "1", BROWSER_HARNESS_REPO, HARNESS_DIR], { timeoutMs: 180000 });
|
|
691
|
+
if (clone.code !== 0) {
|
|
692
|
+
return fail(`browser-harness clone failed (exit ${clone.code}). ${clone.out.slice(-400)}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Pin to the known-good commit (fetch the exact SHA, hard-reset). Best
|
|
696
|
+
// effort: a transient fetch failure falls back to the existing checkout.
|
|
697
|
+
await sh("git", ["-C", HARNESS_DIR, "fetch", "--depth", "1", "origin", BROWSER_HARNESS_PIN], {
|
|
698
|
+
timeoutMs: 120000,
|
|
699
|
+
});
|
|
700
|
+
await sh("git", ["-C", HARNESS_DIR, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: 60000 });
|
|
701
|
+
// Install the CLI via uv tool (lands at ~/.local/bin/browser-harness).
|
|
702
|
+
// --force so a refreshed source / changed entry point is reinstalled.
|
|
703
|
+
const inst = await sh(uv, ["tool", "install", "--force", "-e", HARNESS_DIR], {
|
|
704
|
+
env: uvEnv,
|
|
705
|
+
timeoutMs: 300000,
|
|
706
|
+
});
|
|
707
|
+
if (inst.code !== 0 || !fs.existsSync(HARNESS_BIN)) {
|
|
708
|
+
return fail(`browser-harness CLI install failed (exit ${inst.code}); ${HARNESS_BIN} missing. ` +
|
|
709
|
+
`${inst.out.slice(-400)}`);
|
|
710
|
+
}
|
|
711
|
+
// Drop the harness daemon's cached code so the next run loads fresh (best effort).
|
|
712
|
+
await sh(HARNESS_BIN, ["--reload"], { timeoutMs: 30000 });
|
|
713
|
+
}
|
|
714
|
+
setStep("harness", "done", HARNESS_BIN);
|
|
715
|
+
// --- Step 7: Google Chrome (the browser the harness drives over CDP) ------
|
|
716
|
+
// The harness scans/scrapes X by steering a REAL Chrome over CDP. The runtime
|
|
717
|
+
// never installed one (Step 5's Playwright Chromium is a different binary the
|
|
718
|
+
// cycle doesn't use), so a Chrome-less Mac passed every step then died with
|
|
719
|
+
// "no Chrome/Chromium binary found." Detect an existing Chrome first; if none,
|
|
720
|
+
// install on macOS via the official DMG using plain `cp` (no sudo, no GUI
|
|
721
|
+
// prompt): try /Applications (group-writable for admins), else ~/Applications
|
|
722
|
+
// (always user-writable). The resolved path is recorded for BH_CHROME_BIN.
|
|
723
|
+
setStep("chrome", "running");
|
|
724
|
+
let chromeBin = detectChromeBin();
|
|
725
|
+
if (chromeBin) {
|
|
726
|
+
setStep("chrome", "done", `found: ${chromeBin}`);
|
|
727
|
+
}
|
|
728
|
+
else if (process.platform === "darwin") {
|
|
729
|
+
// One self-contained script: download DMG, mount, copy to the first
|
|
730
|
+
// writable Applications dir, unmount, clean up. Echoes INSTALLED:<path> on
|
|
731
|
+
// success so we record the exact binary (handles the /Applications vs
|
|
732
|
+
// ~/Applications branch without re-detecting spaces-in-path quirks).
|
|
733
|
+
const script = [
|
|
734
|
+
"set -e",
|
|
735
|
+
'DMG="$(mktemp -t saps-gchrome).dmg"',
|
|
736
|
+
'MNT="$(mktemp -d -t saps-gchrome-mnt)"',
|
|
737
|
+
'cleanup() { hdiutil detach "$MNT" -quiet 2>/dev/null || true; rm -f "$DMG"; rmdir "$MNT" 2>/dev/null || true; }',
|
|
738
|
+
"trap cleanup EXIT",
|
|
739
|
+
`curl -fsSL -o "$DMG" "${GOOGLE_CHROME_DMG}"`,
|
|
740
|
+
'hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MNT"',
|
|
741
|
+
'if cp -R "$MNT/Google Chrome.app" /Applications/ 2>/dev/null; then',
|
|
742
|
+
' echo "INSTALLED:/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"',
|
|
743
|
+
"else",
|
|
744
|
+
' mkdir -p "$HOME/Applications"',
|
|
745
|
+
' cp -R "$MNT/Google Chrome.app" "$HOME/Applications/"',
|
|
746
|
+
' echo "INSTALLED:$HOME/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"',
|
|
747
|
+
"fi",
|
|
748
|
+
].join("\n");
|
|
749
|
+
const r = await bash(script, 300000);
|
|
750
|
+
const m = r.out.match(/INSTALLED:(.+)/);
|
|
751
|
+
const installed = m ? m[1].trim() : "";
|
|
752
|
+
if (r.code === 0 && installed) {
|
|
753
|
+
try {
|
|
754
|
+
fs.accessSync(installed, fs.constants.X_OK);
|
|
755
|
+
chromeBin = installed;
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
/* copied but not executable? fall through to re-detect */
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (!chromeBin)
|
|
762
|
+
chromeBin = detectChromeBin();
|
|
763
|
+
if (chromeBin) {
|
|
764
|
+
setStep("chrome", "done", `installed: ${chromeBin}`);
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
return fail(`Google Chrome install failed (exit ${r.code}). The scanner drives ` +
|
|
768
|
+
`Chrome over CDP and none was found. Install Google Chrome from ` +
|
|
769
|
+
`https://www.google.com/chrome/ and re-run setup. ${r.out.slice(-300)}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
// Non-macOS (managed Linux VMs): we don't auto-install. The cycle's own
|
|
774
|
+
// PATH-based _resolve_chrome_bin may still find one at run time, so this is
|
|
775
|
+
// a soft note, not a hard fail.
|
|
776
|
+
setStep("chrome", "done", "no Chrome found; on non-macOS the host must provide google-chrome/chromium on PATH");
|
|
777
|
+
}
|
|
778
|
+
// --- Step 8: menu bar app (macOS status-bar mini-dashboard) --------------
|
|
779
|
+
// Non-fatal: a menu bar failure must never block a usable runtime, so on any
|
|
780
|
+
// problem we mark the step errored and still persist runtime.json below.
|
|
781
|
+
setStep("menubar", "running");
|
|
782
|
+
if (process.platform !== "darwin") {
|
|
783
|
+
setStep("menubar", "done", "skipped (macOS only)");
|
|
784
|
+
}
|
|
785
|
+
else if (menubarStopped()) {
|
|
786
|
+
// A runtime repair/re-provision must not resurrect a tray the user
|
|
787
|
+
// explicitly quit; an explicit start clears the flag first.
|
|
788
|
+
setStep("menubar", "done", "skipped (user stopped the menu bar)");
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
const mb = await installMenubar(uv, uvEnv, VENV_PYTHON);
|
|
792
|
+
setStep("menubar", mb.ok ? "done" : "error", mb.detail);
|
|
793
|
+
// Non-fatal step, so the only prior signal of a menu bar install failure was
|
|
794
|
+
// a local install-progress.json entry (invisible to us). Report it so "menu
|
|
795
|
+
// bar didn't start" becomes a real Sentry event with the failing detail.
|
|
796
|
+
if (!mb.ok) {
|
|
797
|
+
captureError(new Error(`menubar install failed: ${mb.detail}`), {
|
|
798
|
+
component: "menubar",
|
|
799
|
+
phase: "install",
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// --- Persist the result ---------------------------------------------------
|
|
804
|
+
const info = {
|
|
805
|
+
python: VENV_PYTHON,
|
|
806
|
+
uv,
|
|
807
|
+
python_version: PYTHON_VERSION,
|
|
808
|
+
repo_dir: resolvedRepo,
|
|
809
|
+
chrome: chromeBin || undefined,
|
|
810
|
+
ready: true,
|
|
811
|
+
provisioned_at: new Date().toISOString(),
|
|
812
|
+
// Stamp the just-materialized pipeline version so ensurePipelineCurrent()
|
|
813
|
+
// can detect a later update without re-extracting on every boot. Only
|
|
814
|
+
// meaningful for the materialized (.mcpb) repo, not a S4L_REPO_DIR clone.
|
|
815
|
+
pipeline_version: resolvedRepo === MATERIALIZED_REPO ? bundledVersion() ?? undefined : undefined,
|
|
816
|
+
};
|
|
817
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
818
|
+
fs.writeFileSync(RUNTIME_JSON, JSON.stringify(info, null, 2) + "\n", "utf-8");
|
|
819
|
+
progress.running = false;
|
|
820
|
+
progress.done = true;
|
|
821
|
+
progress.ok = true;
|
|
822
|
+
writeProgress(progress);
|
|
823
|
+
return progress;
|
|
824
|
+
}
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
// Menu bar app provisioning.
|
|
827
|
+
//
|
|
828
|
+
// installMenubar copies the rumps app to a stable state-dir location, installs
|
|
829
|
+
// rumps into the owned venv, and (re)loads a KeepAlive LaunchAgent. ensureMenubar
|
|
830
|
+
// is the cheap, idempotent boot-time path: a no-op when it's already installed
|
|
831
|
+
// and loaded, so existing installs pick up the menu bar on the next Claude
|
|
832
|
+
// restart without re-running the whole provision.
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
// The bundled menu bar source: <ext>/menubar in a packed .mcpb (dist/runtime.js
|
|
835
|
+
// -> ../menubar), the same path for a tsc dev build (mcp/dist -> mcp/menubar),
|
|
836
|
+
// and the repo clone for an npm install.
|
|
837
|
+
function menubarSourceDir() {
|
|
838
|
+
const candidates = [
|
|
839
|
+
path.join(__dirname, "..", "menubar"),
|
|
840
|
+
path.join(resolveRepoDir(), "mcp", "menubar"),
|
|
841
|
+
];
|
|
842
|
+
for (const c of candidates) {
|
|
843
|
+
try {
|
|
844
|
+
if (fs.existsSync(path.join(c, "s4l_menubar.py")))
|
|
845
|
+
return c;
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
/* try next */
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
// KeepAlive { SuccessfulExit: false } so a clean Quit (exit 0) stays quit until
|
|
854
|
+
// next login (RunAtLoad), while a crash relaunches. No StartInterval — this is a
|
|
855
|
+
// long-running agent, not a cron job.
|
|
856
|
+
function menubarPlistXml(python) {
|
|
857
|
+
const menubarPath = [
|
|
858
|
+
path.dirname(python),
|
|
859
|
+
path.join(os.homedir(), ".local", "bin"),
|
|
860
|
+
"/opt/homebrew/bin",
|
|
861
|
+
"/usr/local/bin",
|
|
862
|
+
"/usr/bin",
|
|
863
|
+
"/bin",
|
|
864
|
+
"/usr/sbin",
|
|
865
|
+
"/sbin",
|
|
866
|
+
].join(":");
|
|
867
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
868
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
869
|
+
<plist version="1.0">
|
|
870
|
+
<dict>
|
|
871
|
+
\t<key>Label</key>
|
|
872
|
+
\t<string>${MENUBAR_LABEL}</string>
|
|
873
|
+
\t<key>ProgramArguments</key>
|
|
874
|
+
\t<array>
|
|
875
|
+
\t\t<string>${python}</string>
|
|
876
|
+
\t\t<string>${MENUBAR_ENTRY}</string>
|
|
877
|
+
\t</array>
|
|
878
|
+
\t<key>RunAtLoad</key>
|
|
879
|
+
\t<true/>
|
|
880
|
+
\t<key>KeepAlive</key>
|
|
881
|
+
\t<dict>
|
|
882
|
+
\t\t<key>SuccessfulExit</key>
|
|
883
|
+
\t\t<false/>
|
|
884
|
+
\t</dict>
|
|
885
|
+
\t<key>ProcessType</key>
|
|
886
|
+
\t<string>Interactive</string>
|
|
887
|
+
\t<key>StandardOutPath</key>
|
|
888
|
+
\t<string>${MENUBAR_OUT_LOG}</string>
|
|
889
|
+
\t<key>StandardErrorPath</key>
|
|
890
|
+
\t<string>${MENUBAR_ERR_LOG}</string>
|
|
891
|
+
\t<key>EnvironmentVariables</key>
|
|
892
|
+
\t<dict>
|
|
893
|
+
\t\t<key>PATH</key>
|
|
894
|
+
\t\t<string>${menubarPath}</string>
|
|
895
|
+
\t\t<key>HOME</key>
|
|
896
|
+
\t\t<string>${os.homedir()}</string>
|
|
897
|
+
\t\t<key>S4L_STATE_DIR</key>
|
|
898
|
+
\t\t<string>${STATE_DIR}</string>
|
|
899
|
+
\t\t<key>SAPS_STATE_DIR</key>
|
|
900
|
+
\t\t<string>${STATE_DIR}</string>
|
|
901
|
+
\t\t<key>S4L_PYTHON</key>
|
|
902
|
+
\t\t<string>${python}</string>
|
|
903
|
+
\t\t<key>SAPS_PYTHON</key>
|
|
904
|
+
\t\t<string>${python}</string>
|
|
905
|
+
\t\t<key>S4L_REPO_DIR</key>
|
|
906
|
+
\t\t<string>${resolveRepoDir()}</string>
|
|
907
|
+
\t\t<key>SAPS_REPO_DIR</key>
|
|
908
|
+
\t\t<string>${resolveRepoDir()}</string>
|
|
909
|
+
\t</dict>
|
|
910
|
+
</dict>
|
|
911
|
+
</plist>
|
|
912
|
+
`;
|
|
913
|
+
}
|
|
914
|
+
export async function menubarLoaded() {
|
|
915
|
+
const r = await sh("launchctl", ["list"], { timeoutMs: 10_000 });
|
|
916
|
+
return r.out.includes(MENUBAR_LABEL);
|
|
917
|
+
}
|
|
918
|
+
// Is the menu bar app expected to be up right now? Only meaningful once the
|
|
919
|
+
// runtime is provisioned (the LaunchAgent isn't installed before that) and only
|
|
920
|
+
// on macOS. Any failure defaults to `true` so the panel never shows a spurious
|
|
921
|
+
// "menu bar down" banner from a flaky launchctl read.
|
|
922
|
+
export async function menubarRunning() {
|
|
923
|
+
if (process.platform !== "darwin")
|
|
924
|
+
return true;
|
|
925
|
+
if (!runtimeReady())
|
|
926
|
+
return true;
|
|
927
|
+
// User explicitly quit the tray: it is down ON PURPOSE, so report "fine" —
|
|
928
|
+
// the dashboard banner must not nag about a state the user chose.
|
|
929
|
+
if (menubarStopped())
|
|
930
|
+
return true;
|
|
931
|
+
try {
|
|
932
|
+
return await menubarLoaded();
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
export async function installMenubar(uv, uvEnv, python) {
|
|
939
|
+
if (process.platform !== "darwin")
|
|
940
|
+
return { ok: true, detail: "skipped (macOS only)" };
|
|
941
|
+
const src = menubarSourceDir();
|
|
942
|
+
if (!src)
|
|
943
|
+
return { ok: false, detail: "menu bar source not found in bundle" };
|
|
944
|
+
// 1. Copy the python to a stable location that survives extension updates.
|
|
945
|
+
try {
|
|
946
|
+
fs.mkdirSync(MENUBAR_DIR, { recursive: true });
|
|
947
|
+
for (const f of fs.readdirSync(src)) {
|
|
948
|
+
if (f.endsWith(".py")) {
|
|
949
|
+
fs.copyFileSync(path.join(src, f), path.join(MENUBAR_DIR, f));
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch (e) {
|
|
954
|
+
return { ok: false, detail: `copy failed: ${e?.message || e}` };
|
|
955
|
+
}
|
|
956
|
+
if (!fs.existsSync(MENUBAR_ENTRY))
|
|
957
|
+
return { ok: false, detail: "entry not copied" };
|
|
958
|
+
// 2. Install rumps into the owned venv (pulls pyobjc-framework-Cocoa).
|
|
959
|
+
const r = await sh(uv, ["pip", "install", "--python", python, "rumps"], {
|
|
960
|
+
env: uvEnv,
|
|
961
|
+
timeoutMs: 300_000,
|
|
962
|
+
});
|
|
963
|
+
if (r.code !== 0) {
|
|
964
|
+
return { ok: false, detail: `rumps install failed (exit ${r.code}). ${r.out.slice(-300)}` };
|
|
965
|
+
}
|
|
966
|
+
// 3. Write + (re)load the LaunchAgent (bootout any prior instance first).
|
|
967
|
+
try {
|
|
968
|
+
fs.mkdirSync(path.dirname(MENUBAR_PLIST), { recursive: true });
|
|
969
|
+
fs.writeFileSync(MENUBAR_PLIST, menubarPlistXml(python), "utf-8");
|
|
970
|
+
}
|
|
971
|
+
catch (e) {
|
|
972
|
+
return { ok: false, detail: `plist write failed: ${e?.message || e}` };
|
|
973
|
+
}
|
|
974
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
975
|
+
await sh("launchctl", ["bootout", `gui/${uid}/${MENUBAR_LABEL}`], { timeoutMs: 15_000 });
|
|
976
|
+
let lr = await sh("launchctl", ["bootstrap", `gui/${uid}`, MENUBAR_PLIST], {
|
|
977
|
+
timeoutMs: 15_000,
|
|
978
|
+
});
|
|
979
|
+
if (lr.code !== 0) {
|
|
980
|
+
lr = await sh("launchctl", ["load", MENUBAR_PLIST], { timeoutMs: 15_000 });
|
|
981
|
+
}
|
|
982
|
+
return { ok: true, detail: MENUBAR_ENTRY };
|
|
983
|
+
}
|
|
984
|
+
export async function ensureMenubar() {
|
|
985
|
+
if (process.platform !== "darwin")
|
|
986
|
+
return { ok: true, skipped: true, detail: "non-macOS" };
|
|
987
|
+
// The user clicked Quit in the tray: stay stopped across Claude restarts,
|
|
988
|
+
// regardless of runtime state. Explicit start paths (restart_menubar tool,
|
|
989
|
+
// queue_setup) clear the flag before calling this.
|
|
990
|
+
if (menubarStopped()) {
|
|
991
|
+
return { ok: true, skipped: true, detail: "user stopped the menu bar (stopped.flag)" };
|
|
992
|
+
}
|
|
993
|
+
if (!runtimeReady())
|
|
994
|
+
return { ok: false, skipped: true, detail: "runtime not ready" };
|
|
995
|
+
if (fs.existsSync(MENUBAR_ENTRY) &&
|
|
996
|
+
fs.existsSync(MENUBAR_PLIST) &&
|
|
997
|
+
(await menubarLoaded())) {
|
|
998
|
+
// Installed — but refresh the stable copy if the bundled menu bar is newer.
|
|
999
|
+
// An .mcpb update ships new menu bar code, yet the running menu bar runs from
|
|
1000
|
+
// this stable copy (so it survives extension replacement); without this it
|
|
1001
|
+
// would stay the version it first installed at. Cheap: content-compare, and
|
|
1002
|
+
// a kickstart only when something actually changed.
|
|
1003
|
+
return await refreshMenubarIfStale();
|
|
1004
|
+
}
|
|
1005
|
+
const uv = findUv();
|
|
1006
|
+
if (!uv)
|
|
1007
|
+
return { ok: false, detail: "uv not found" };
|
|
1008
|
+
const uvEnv = {
|
|
1009
|
+
UV_PYTHON_INSTALL_DIR: path.join(RUNTIME_DIR, "python"),
|
|
1010
|
+
};
|
|
1011
|
+
return installMenubar(uv, uvEnv, resolvePython());
|
|
1012
|
+
}
|
|
1013
|
+
// Re-copy the bundled menu bar python into the stable dir when it differs from
|
|
1014
|
+
// what's installed, and kickstart the launchd job so the running menu bar picks
|
|
1015
|
+
// up the new code. Content-compare keeps this a no-op on an unchanged boot, so
|
|
1016
|
+
// it's cheap to call on every ensureMenubar(). This is what makes menu bar
|
|
1017
|
+
// changes (e.g. the "Update now & restart Claude Desktop" button) actually ship on an .mcpb update.
|
|
1018
|
+
async function refreshMenubarIfStale() {
|
|
1019
|
+
const src = menubarSourceDir();
|
|
1020
|
+
if (!src)
|
|
1021
|
+
return { ok: true, skipped: true, detail: "no bundle source to refresh from" };
|
|
1022
|
+
let changed = false;
|
|
1023
|
+
try {
|
|
1024
|
+
for (const f of fs.readdirSync(src)) {
|
|
1025
|
+
if (!f.endsWith(".py"))
|
|
1026
|
+
continue;
|
|
1027
|
+
const s = fs.readFileSync(path.join(src, f));
|
|
1028
|
+
const dPath = path.join(MENUBAR_DIR, f);
|
|
1029
|
+
const d = fs.existsSync(dPath) ? fs.readFileSync(dPath) : Buffer.alloc(0);
|
|
1030
|
+
if (!s.equals(d)) {
|
|
1031
|
+
fs.writeFileSync(dPath, s);
|
|
1032
|
+
changed = true;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
catch (e) {
|
|
1037
|
+
return { ok: true, skipped: true, detail: `menu bar refresh check failed: ${e?.message || e}` };
|
|
1038
|
+
}
|
|
1039
|
+
// The plist bakes S4L_REPO_DIR/S4L_PYTHON at write time and was historically
|
|
1040
|
+
// never rewritten, so a box whose repo resolution changed (e.g. a stray git
|
|
1041
|
+
// checkout healed at boot) kept feeding the menu bar (and every snapshot.py
|
|
1042
|
+
// it spawns) the stale repo, which is what pins the displayed version and the
|
|
1043
|
+
// update banner. Regenerate and compare; a drifted plist needs a full
|
|
1044
|
+
// bootout/bootstrap (env changes don't apply on kickstart).
|
|
1045
|
+
let plistChanged = false;
|
|
1046
|
+
try {
|
|
1047
|
+
const want = menubarPlistXml(resolvePython());
|
|
1048
|
+
let have = "";
|
|
1049
|
+
try {
|
|
1050
|
+
have = fs.readFileSync(MENUBAR_PLIST, "utf-8");
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
have = "";
|
|
1054
|
+
}
|
|
1055
|
+
if (want !== have) {
|
|
1056
|
+
fs.mkdirSync(path.dirname(MENUBAR_PLIST), { recursive: true });
|
|
1057
|
+
fs.writeFileSync(MENUBAR_PLIST, want, "utf-8");
|
|
1058
|
+
plistChanged = true;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
/* best effort; a failed plist refresh must not block the .py refresh */
|
|
1063
|
+
}
|
|
1064
|
+
if (!changed && !plistChanged)
|
|
1065
|
+
return { ok: true, skipped: true, detail: "menu bar already current" };
|
|
1066
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
1067
|
+
if (plistChanged) {
|
|
1068
|
+
await sh("launchctl", ["bootout", `gui/${uid}/${MENUBAR_LABEL}`], { timeoutMs: 15_000 });
|
|
1069
|
+
const lr = await sh("launchctl", ["bootstrap", `gui/${uid}`, MENUBAR_PLIST], {
|
|
1070
|
+
timeoutMs: 15_000,
|
|
1071
|
+
});
|
|
1072
|
+
if (lr.code !== 0) {
|
|
1073
|
+
await sh("launchctl", ["load", MENUBAR_PLIST], { timeoutMs: 15_000 });
|
|
1074
|
+
}
|
|
1075
|
+
return { ok: true, detail: "menu bar plist refreshed + agent reloaded" };
|
|
1076
|
+
}
|
|
1077
|
+
await sh("launchctl", ["kickstart", "-k", `gui/${uid}/${MENUBAR_LABEL}`], { timeoutMs: 15_000 });
|
|
1078
|
+
return { ok: true, detail: "menu bar refreshed + restarted to bundled version" };
|
|
1079
|
+
}
|