social-autoposter 1.2.0 → 1.3.1

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.
Files changed (208) hide show
  1. package/README.md +7 -8
  2. package/SKILL.md +22 -4
  3. package/bin/auth.js +110 -0
  4. package/bin/cli.js +261 -70
  5. package/bin/cookie-helper.js +315 -0
  6. package/bin/platform.js +64 -0
  7. package/bin/scheduler/index.js +14 -0
  8. package/bin/scheduler/launchd.js +518 -0
  9. package/bin/scheduler/systemd.js +313 -0
  10. package/bin/server.js +13813 -1109
  11. package/package.json +17 -5
  12. package/requirements.txt +7 -0
  13. package/schema-postgres.sql +208 -8
  14. package/scripts/_dm_icp_batch.py +44 -0
  15. package/scripts/_gsc_roi_query.py +231 -0
  16. package/scripts/_insert_post_013.py +125 -0
  17. package/scripts/_li_discover_pending.py +157 -0
  18. package/scripts/_log_cyrano_apartmenthacks.py +54 -0
  19. package/scripts/_seo_lane_roi.py +340 -0
  20. package/scripts/_serp_report.py +223 -0
  21. package/scripts/_serp_vs_gsc_report.py +253 -0
  22. package/scripts/add_deploy_metadata.py +70 -0
  23. package/scripts/amplitude_24h_signups.py +468 -0
  24. package/scripts/amplitude_signups.py +177 -0
  25. package/scripts/amplitude_user_lookup.py +221 -0
  26. package/scripts/audit_signup_wiring.py +202 -0
  27. package/scripts/backfill_aggregate_stats.py +143 -0
  28. package/scripts/backfill_claude_session_subagents.py +290 -0
  29. package/scripts/backfill_ensure_dms.py +132 -0
  30. package/scripts/backfill_icp_precheck.py +231 -0
  31. package/scripts/backfill_linkedin_activity_urns.py +448 -0
  32. package/scripts/backfill_mk0r_get_started.py +163 -0
  33. package/scripts/backfill_real_clicks.py +257 -0
  34. package/scripts/backfill_run_monitor.py +299 -0
  35. package/scripts/backfill_seo_authors.py +241 -0
  36. package/scripts/backfill_seo_engagement.py +299 -0
  37. package/scripts/backfill_target_project.py +112 -0
  38. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  39. package/scripts/batch_send_dms.py +13 -0
  40. package/scripts/blog_refactor_single_route.py +196 -0
  41. package/scripts/campaign_bump.py +23 -19
  42. package/scripts/check_analytics_wiring.py +873 -0
  43. package/scripts/check_backfill_replied.py +107 -0
  44. package/scripts/check_contrast.py +425 -0
  45. package/scripts/check_deploy_wiring.py +138 -0
  46. package/scripts/check_improve_runs.py +32 -0
  47. package/scripts/check_layout_wiring.py +287 -0
  48. package/scripts/check_link_rules.py +1 -1
  49. package/scripts/check_pep604_annotations.py +163 -0
  50. package/scripts/check_unread_web_chats.py +108 -0
  51. package/scripts/claim_web_chat.py +67 -0
  52. package/scripts/classify_all_dms.py +163 -0
  53. package/scripts/cleanup_moltbook_dupes_16060.py +146 -0
  54. package/scripts/cohort_score_distribution.py +112 -0
  55. package/scripts/daily_stats_email.py +44 -8
  56. package/scripts/db.py +100 -11
  57. package/scripts/discover_linkedin_candidates.py +896 -0
  58. package/scripts/dm_conversation.py +1181 -24
  59. package/scripts/dm_helper.py +47 -0
  60. package/scripts/dm_outreach_helper.py +147 -0
  61. package/scripts/dm_send_log.py +99 -0
  62. package/scripts/dm_short_links.py +918 -0
  63. package/scripts/dump_twitter_storage.py +25 -0
  64. package/scripts/dump_web_chat_history.py +86 -0
  65. package/scripts/engage_github.py +49 -28
  66. package/scripts/engage_reddit.py +796 -116
  67. package/scripts/engagement_styles.py +686 -71
  68. package/scripts/engagement_styles_extra.json +65 -0
  69. package/scripts/enrich_twitter_candidates.py +37 -40
  70. package/scripts/extract_user_messages_today.py +291 -0
  71. package/scripts/fazm_seo_health.py +109 -0
  72. package/scripts/fetch_prospect_profile.py +226 -0
  73. package/scripts/fetch_twitter_t1.py +122 -0
  74. package/scripts/find_threads.py +81 -29
  75. package/scripts/fix_mdx_light_mode.py +73 -0
  76. package/scripts/fix_svg_paragraph_wrap.py +59 -0
  77. package/scripts/funnel_per_day.py +194 -0
  78. package/scripts/generation_trace.py +124 -0
  79. package/scripts/get_run_cost.py +113 -0
  80. package/scripts/github_tools.py +104 -15
  81. package/scripts/historical_engagement.py +96 -0
  82. package/scripts/http_api.py +134 -0
  83. package/scripts/identity.py +248 -0
  84. package/scripts/ig_collate_transcripts.py +62 -0
  85. package/scripts/ig_post_type_picker.py +114 -0
  86. package/scripts/ingest_human_dm_replies.py +253 -0
  87. package/scripts/ingest_human_seo_replies.py +289 -0
  88. package/scripts/ingest_web_chat_replies.py +242 -0
  89. package/scripts/install_lane_digest.py +222 -0
  90. package/scripts/install_lane_monitor.py +141 -0
  91. package/scripts/li_discover_insert.py +173 -0
  92. package/scripts/li_process_notifications.py +153 -0
  93. package/scripts/link_tail.py +370 -0
  94. package/scripts/linkedin_api.py +191 -26
  95. package/scripts/linkedin_browser.py +487 -1440
  96. package/scripts/linkedin_url.py +253 -0
  97. package/scripts/log_claude_session.py +649 -0
  98. package/scripts/log_draft.py +94 -0
  99. package/scripts/log_linkedin_search_attempts.py +117 -0
  100. package/scripts/log_post.py +351 -0
  101. package/scripts/log_run.py +162 -2
  102. package/scripts/log_twitter_search_attempts.py +84 -0
  103. package/scripts/log_twitter_skips.py +236 -0
  104. package/scripts/lookup_post.py +89 -0
  105. package/scripts/mark_web_chat_processed.py +45 -0
  106. package/scripts/mcp_lock_proxy.py +370 -0
  107. package/scripts/migrate_dm_links.py +128 -0
  108. package/scripts/migrate_link_clicks.py +97 -0
  109. package/scripts/migrate_post_links.py +88 -0
  110. package/scripts/migrate_replies_stats.py +45 -0
  111. package/scripts/migrate_subreddit_bans_to_objects.py +113 -0
  112. package/scripts/moltbook_post.py +25 -5
  113. package/scripts/moltbook_tools.py +159 -0
  114. package/scripts/octolens_threads.py +10 -1
  115. package/scripts/octolens_twitter_batch.py +23 -7
  116. package/scripts/octolens_twitter_cdp.py +20 -7
  117. package/scripts/pending_threads.py +277 -0
  118. package/scripts/phase_d_new_comments.py +2 -2
  119. package/scripts/pick_project.py +27 -10
  120. package/scripts/pick_thread_target.py +87 -22
  121. package/scripts/pick_twitter_thread_target.py +245 -0
  122. package/scripts/poll_web_chat.py +72 -0
  123. package/scripts/post_github.py +925 -344
  124. package/scripts/post_reddit.py +1941 -156
  125. package/scripts/precompute_dashboard_stats.py +298 -0
  126. package/scripts/progress.py +88 -0
  127. package/scripts/project_deploy_status.py +272 -0
  128. package/scripts/project_excludes.py +359 -0
  129. package/scripts/project_slugs.py +91 -0
  130. package/scripts/project_stats.py +23 -13
  131. package/scripts/project_stats_json.py +1207 -0
  132. package/scripts/promote_engagement_styles.py +209 -0
  133. package/scripts/reddit_browser.py +629 -67
  134. package/scripts/reddit_browser_lock.py +571 -0
  135. package/scripts/reddit_chat_sync.py +710 -0
  136. package/scripts/reddit_tools.py +538 -72
  137. package/scripts/reply_db.py +134 -17
  138. package/scripts/reply_insert.py +98 -0
  139. package/scripts/ripen_reddit_plan.py +478 -0
  140. package/scripts/run_moltbook_cycle.py +540 -0
  141. package/scripts/scan_dm_candidates.py +254 -22
  142. package/scripts/scan_moltbook_replies.py +246 -0
  143. package/scripts/scan_reddit_replies.py +377 -0
  144. package/scripts/scan_twitter_mentions_browser.py +233 -0
  145. package/scripts/scan_twitter_thread_followups.py +243 -0
  146. package/scripts/score_linkedin_candidates.py +417 -0
  147. package/scripts/score_twitter_candidates.py +81 -46
  148. package/scripts/scrape_linkedin_comment_stats.py +495 -0
  149. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  150. package/scripts/scrape_reddit_views.py +169 -49
  151. package/scripts/send_comment_replies.py +110 -0
  152. package/scripts/send_web_chat_reply.py +181 -0
  153. package/scripts/seo_health_all_projects.py +94 -0
  154. package/scripts/socialcrawl.py +116 -0
  155. package/scripts/strike_alert.py +257 -0
  156. package/scripts/sweep_guide_chrome.py +212 -0
  157. package/scripts/sweep_post_link_clicks.py +545 -0
  158. package/scripts/sync_web_chat_config.py +144 -0
  159. package/scripts/test_own_reply_dedup.py +56 -0
  160. package/scripts/top_dud_linkedin_queries.py +90 -0
  161. package/scripts/top_dud_reddit_queries.py +67 -0
  162. package/scripts/top_dud_twitter_queries.py +85 -0
  163. package/scripts/top_linkedin_queries.py +68 -0
  164. package/scripts/top_omitted_reddit_topics.py +91 -0
  165. package/scripts/top_performers.py +268 -54
  166. package/scripts/top_search_topics.py +289 -0
  167. package/scripts/top_twitter_queries.py +199 -0
  168. package/scripts/twitter_batch_phase.py +144 -0
  169. package/scripts/twitter_browser.py +1060 -129
  170. package/scripts/twitter_compose_dm.py +1 -1
  171. package/scripts/twitter_gen_links.py +226 -0
  172. package/scripts/twitter_post_plan.py +493 -0
  173. package/scripts/twitter_supply_signal.py +102 -0
  174. package/scripts/unclaim_web_chat.py +40 -0
  175. package/scripts/update_linkedin_stats_from_feed.py +355 -0
  176. package/scripts/update_stats.py +1338 -196
  177. package/scripts/watchdog_hung_runs.py +318 -0
  178. package/scripts/write_generation_trace.py +73 -0
  179. package/setup/SKILL.md +12 -12
  180. package/skill/engage.sh +63 -216
  181. package/skill/run-github.sh +73 -10
  182. package/skill/run-linkedin.sh +818 -69
  183. package/skill/run-moltbook.sh +22 -70
  184. package/skill/run-reddit-search.sh +476 -0
  185. package/skill/run-reddit-threads.sh +791 -0
  186. package/skill/run-twitter-cycle.sh +1211 -0
  187. package/skill/stats.sh +423 -264
  188. package/.env.example +0 -11
  189. package/scripts/backfill_twitter_urls.py +0 -223
  190. package/scripts/backfill_twitter_urls_v2.py +0 -255
  191. package/scripts/backfill_twitter_urls_v3.py +0 -345
  192. package/scripts/diagnose_linkedin_agent.py +0 -196
  193. package/scripts/find_tweets.py +0 -154
  194. package/scripts/linkedin_auth_check.py +0 -415
  195. package/scripts/log_fazm_linkedin.py +0 -62
  196. package/scripts/log_fazm_linkedin_batch2.py +0 -111
  197. package/scripts/octolens_twitter_read.py +0 -46
  198. package/scripts/phase_d_resolve.py +0 -226
  199. package/scripts/reconcile_reply_urls.py +0 -114
  200. package/scripts/recover_linkedin_urls.py +0 -301
  201. package/scripts/run_pieline_linkedin_batch.py +0 -165
  202. package/scripts/scan_linkedin_notifications.py +0 -273
  203. package/scripts/scan_replies.py +0 -547
  204. package/scripts/scan_twitter_mentions.py +0 -175
  205. package/scripts/scrape_linkedin_stats.py +0 -173
  206. package/scripts/twitter_api.py +0 -258
  207. package/skill/run-reddit.sh +0 -111
  208. package/skill/run-twitter.sh +0 -108
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Ships as a Claude Code skill plus a set of standalone Python helpers and macOS launchd jobs.
4
4
 
5
- Posts are written to a shared Neon Postgres database via `DATABASE_URL` in `~/social-autoposter/.env`. Each platform drives its own persistent Playwright MCP browser profile, so logins survive across runs.
5
+ Posts are written to a Neon Postgres database via `DATABASE_URL` in `~/social-autoposter/.env`. Bring your own Neon DB and apply `schema-postgres.sql` once. Each platform drives its own persistent Playwright MCP browser profile, so logins survive across runs.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -29,7 +29,7 @@ npx social-autoposter init
29
29
  `bin/cli.js` does all of the wiring in one shot:
30
30
 
31
31
  1. Copies `scripts/`, `skill/`, `setup/`, `SKILL.md`, `schema-postgres.sql`, and `browser-agent-configs/` into `~/social-autoposter/`
32
- 2. Creates `config.json` from `config.example.json` and `.env` from `.env.example` (the shared Neon `DATABASE_URL` is pre-filled)
32
+ 2. Creates `config.json` from `config.example.json` and writes a blank `.env` template (fill in your own `DATABASE_URL` and optional `MOLTBOOK_API_KEY`)
33
33
  3. Installs `psycopg2-binary` via `pip3` if missing
34
34
  4. Generates launchd plists in `~/social-autoposter/launchd/` with the user's actual `HOME` and `PATH`
35
35
  5. Installs the Playwright MCP configs to `~/.claude/browser-agent-configs/` (twitter, reddit, linkedin) with `__HOME__` and `__NODE_BIN__` placeholders substituted. Existing files are left alone, so any window-position tweaks survive `npx social-autoposter update`.
@@ -63,7 +63,7 @@ launchd ──▶ skill/run-{platform}.sh ──▶ claude -p --strict-mcp-
63
63
  │ │
64
64
  │ └──▶ ~/.claude/browser-profiles/{platform}/ (persistent userDataDir)
65
65
 
66
- ├──▶ scripts/find_{tweets,threads}.py (no browser, API + DB dedup)
66
+ ├──▶ scripts/find_threads.py, top_twitter_queries.py (no browser, API + DB dedup)
67
67
  ├──▶ scripts/pick_project.py (weighted project rotation)
68
68
  ├──▶ scripts/top_performers.py (feedback report from past stats)
69
69
  └──▶ Neon Postgres (DATABASE_URL in .env)
@@ -78,19 +78,18 @@ Each `skill/run-*.sh`:
78
78
  5. Calls `find_*.py` for API-side candidates already deduped against the DB
79
79
  6. Spawns a child Claude process with `--strict-mcp-config` so it only sees the one platform's browser MCP
80
80
 
81
- The launchd schedules from `bin/cli.js`:
81
+ The launchd schedules generated by `bin/cli.js` on install:
82
82
 
83
83
  | Job | Cadence |
84
84
  |-----|---------|
85
- | `com.m13v.social-autoposter` (`run.sh`) | every 3600 s (hourly) |
86
85
  | `com.m13v.social-stats` (`stats.sh`) | every 21600 s (6 h) |
87
86
  | `com.m13v.social-engage` (`engage.sh`) | every 21600 s (6 h) |
88
87
 
89
- Per-platform plists in `launchd/` (twitter, reddit, linkedin, moltbook, github, octolens, audit, dm-replies, scan-replies, etc.) use either `StartInterval` or `StartCalendarInterval` for fixed wall-clock times. Activate them with:
88
+ All per-platform plists live in `launchd/` (reddit-search, reddit-threads, twitter-cycle, linkedin, moltbook, github, octolens, audit, dm-replies-*, link-edit-*, scan-reddit-replies, scan-moltbook-replies, etc.) and use either `StartInterval` or `StartCalendarInterval` for fixed wall-clock times. Activate any of them with:
90
89
 
91
90
  ```bash
92
- ln -sf ~/social-autoposter/launchd/com.m13v.social-twitter.plist ~/Library/LaunchAgents/
93
- launchctl load ~/Library/LaunchAgents/com.m13v.social-twitter.plist
91
+ ln -sf ~/social-autoposter/launchd/com.m13v.social-twitter-cycle.plist ~/Library/LaunchAgents/
92
+ launchctl load ~/Library/LaunchAgents/com.m13v.social-twitter-cycle.plist
94
93
  ```
95
94
 
96
95
  ## Skill commands
package/SKILL.md CHANGED
@@ -53,7 +53,8 @@ Standalone Python scripts — no LLM needed.
53
53
 
54
54
  ```bash
55
55
  python3 ~/social-autoposter/scripts/find_threads.py --include-moltbook
56
- python3 ~/social-autoposter/scripts/scan_replies.py
56
+ python3 ~/social-autoposter/scripts/scan_reddit_replies.py
57
+ python3 ~/social-autoposter/scripts/scan_moltbook_replies.py
57
58
  python3 ~/social-autoposter/scripts/update_stats.py --quiet
58
59
  ```
59
60
 
@@ -205,7 +206,7 @@ Daily-cadence original Reddit threads across all products, automated via launchd
205
206
  - `own_community`: `{subreddit, cadence, floor_days}` (optional). Defaults to 1-day floor.
206
207
  - `external_subreddits`: list of external subs (default 3-day floor, override via `external_floor_days`)
207
208
  - `topic_angles`: discussion-starter ideas the agent picks from
208
- - `voice_notes`: per-thread voice guidance on top of `projects[].voice`
209
+ - Voice guidance comes from `projects[].voice` (tone, never)
209
210
  - `content_sources.guide_dir` / `link_base`: optional source paths/URLs
210
211
  - `dynamic_context.day_counter` / `static_facts`: live-calculated facts injected into the prompt
211
212
 
@@ -213,7 +214,7 @@ Daily-cadence original Reddit threads across all products, automated via launchd
213
214
 
214
215
  **Picker** (`scripts/pick_thread_target.py`): weighted project selection with:
215
216
  - Per-sub floor-days filter (queries `posts` table for this account's last original thread)
216
- - `banned_subreddits` filter (top-level config, all groups flattened)
217
+ - `subreddit_bans` filter: `banned` (can't post or comment) + `skip_threads` (threads blocked, comments OK)
217
218
  - Own-community candidates always picked first when eligible
218
219
 
219
220
  **Schedule**: `com.m13v.social-reddit-threads.plist` fires 4x/day at 00:15, 06:15, 12:15, 18:15.
@@ -236,7 +237,8 @@ After running, view updated stats at `https://s4l.ai/stats/[handle]`. Stats are
236
237
 
237
238
  ### Phase A: Scan for replies (no browser)
238
239
  ```bash
239
- python3 ~/social-autoposter/scripts/scan_replies.py
240
+ python3 ~/social-autoposter/scripts/scan_reddit_replies.py
241
+ python3 ~/social-autoposter/scripts/scan_moltbook_replies.py
240
242
  ```
241
243
 
242
244
  ### Phase B: Respond to pending replies
@@ -305,6 +307,22 @@ GOOD title: "just did my 7th course, some things that surprised me"
305
307
  BAD body: Structured with headers, bold, numbered lists, "As a tech founder..."
306
308
  GOOD body: Paragraphs, incomplete thoughts, personal details, casual tone, ends with a genuine question
307
309
 
310
+ ### Bad vs Good (DM Replies)
311
+
312
+ DM replies are texting-style. 1 to 3 sentences. Always reference something specific from the inbound. No unearned call offers, no fabricated links, no time-bound commitments. Booking links only when the matched project has `booking_link_auto_share: true` AND `qualification_status=qualified` on the DM row.
313
+
314
+ BAD: "Hey! I saw your comment on r/startups about agent orchestration. I'd love to share what we're working on, would you be open to a quick call?" (cold-pitch shape, premature call ask)
315
+ GOOD: "yo the point about agents racing on the same file hit home, we solved it with worktrees per agent. what's your setup?"
316
+
317
+ BAD: "Great question! Our product handles exactly that scenario. Check out [link] for more details." (sales register, leading with link in an early DM)
318
+ GOOD: "we hit that too, ended up using the accessibility API route because screenshot-based kept flaking on retina displays"
319
+
320
+ BAD: "Absolutely! Let's do Thursday at 3pm, I'll send an invite." (time-bound commitment, bot has no calendar authority)
321
+ GOOD: "yeah easier to figure it out here, what specifically are you trying to wire up?"
322
+
323
+ BAD: "I totally understand your hesitation. But our solution is different because..." (defensive, pushy rebuttal)
324
+ GOOD: "makes sense, we kicked it around for 6 months before pulling the trigger. what's been the blocker on your end?"
325
+
308
326
  ---
309
327
 
310
328
  ## Tiered Reply Strategy
package/bin/auth.js ADDED
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // Firebase auth for the dashboard when CLIENT_MODE=1.
4
+ // When CLIENT_MODE is unset (local operator use), authorize() is a no-op
5
+ // and returns a synthetic admin principal so every existing route keeps
6
+ // working unchanged.
7
+
8
+ let _admin = null;
9
+
10
+ function getAdmin() {
11
+ if (_admin) return _admin;
12
+ const admin = require('firebase-admin');
13
+ if (!admin.apps.length) {
14
+ const projectId = process.env.FIREBASE_PROJECT_ID || 's4l-app-prod';
15
+ admin.initializeApp({ projectId });
16
+ }
17
+ _admin = admin;
18
+ return _admin;
19
+ }
20
+
21
+ const CLIENT_MODE = process.env.CLIENT_MODE === '1';
22
+
23
+ // Routes that require an admin claim even when authenticated.
24
+ // Clients authenticated with only a project scope cannot hit these.
25
+ const ADMIN_ONLY_PATTERNS = [
26
+ /^\/api\/pause$/,
27
+ /^\/api\/resume$/,
28
+ /^\/api\/jobs\/[^/]+\/(toggle|run|stop|interval)$/,
29
+ /^\/api\/phase\/[^/]+\/interval$/,
30
+ /^\/api\/config$/,
31
+ /^\/api\/env$/,
32
+ /^\/api\/logs(\/.*)?$/,
33
+ /^\/api\/webhooks(\/.*)?$/,
34
+ /^\/api\/status$/,
35
+ /^\/api\/pending$/,
36
+ ];
37
+
38
+ function isAdminOnly(pathname) {
39
+ return ADMIN_ONLY_PATTERNS.some(re => re.test(pathname));
40
+ }
41
+
42
+ function extractToken(req) {
43
+ const h = req.headers['authorization'] || req.headers['Authorization'];
44
+ if (!h) return null;
45
+ const m = String(h).match(/^Bearer\s+(.+)$/);
46
+ return m ? m[1].trim() : null;
47
+ }
48
+
49
+ async function verifyAuth(req, pathname) {
50
+ if (!CLIENT_MODE) {
51
+ return { ok: true, user: { uid: 'local', email: 'local', admin: true, projects: [] } };
52
+ }
53
+ const token = extractToken(req);
54
+ if (!token) {
55
+ console.warn(JSON.stringify({ event: 'auth_reject', reason: 'missing_token', path: pathname, authHeaderPresent: !!(req.headers['authorization'] || req.headers['Authorization']) }));
56
+ return { ok: false, status: 401, error: 'missing_token' };
57
+ }
58
+ try {
59
+ const decoded = await getAdmin().auth().verifyIdToken(token);
60
+ const user = {
61
+ uid: decoded.uid,
62
+ email: decoded.email || null,
63
+ admin: decoded.admin === true,
64
+ projects: Array.isArray(decoded.projects) ? decoded.projects : [],
65
+ };
66
+ if (isAdminOnly(pathname) && !user.admin) {
67
+ console.warn(JSON.stringify({ event: 'auth_reject', reason: 'admin_required', path: pathname, uid: user.uid, email: user.email }));
68
+ return { ok: false, status: 403, error: 'admin_required' };
69
+ }
70
+ return { ok: true, user };
71
+ } catch (e) {
72
+ console.warn(JSON.stringify({ event: 'auth_reject', reason: 'invalid_token', path: pathname, errCode: e.code || null, errMessage: e.message, tokenHead: token.slice(0, 20), tokenLen: token.length }));
73
+ return { ok: false, status: 401, error: 'invalid_token', detail: e.message };
74
+ }
75
+ }
76
+
77
+ function scopedProjects(user, requested) {
78
+ if (user.admin) {
79
+ return requested && requested !== 'all' ? [requested] : null;
80
+ }
81
+ if (!user.projects.length) return [];
82
+ if (!requested || requested === 'all') return user.projects.slice();
83
+ return user.projects.includes(requested) ? [requested] : [];
84
+ }
85
+
86
+ // Returns { clause, ok } where clause is a " AND <column> IN ('a','b')" fragment
87
+ // (possibly empty when admin + no filter) and ok=false means the user has no
88
+ // access (non-admin with empty projects claim) so the handler should 403.
89
+ // Project names are validated against a conservative charset; single quotes
90
+ // are also SQL-escaped below as defense in depth. Spaces are allowed because
91
+ // real config.json names include them ('WhatsApp MCP', 'AI Browser Profile',
92
+ // 'macOS MCP', 'macOS Session Replay').
93
+ const PROJECT_NAME_RE = /^[A-Za-z0-9 _\-]{1,64}$/;
94
+
95
+ function projectClause(user, column, requested) {
96
+ const list = scopedProjects(user, requested);
97
+ if (list === null) return { clause: '', ok: true }; // admin, unfiltered
98
+ const safe = list.filter(p => PROJECT_NAME_RE.test(p));
99
+ if (!safe.length) return { clause: '', ok: false };
100
+ const quoted = safe.map(p => `'${p.replace(/'/g, "''")}'`).join(',');
101
+ return { clause: ` AND ${column} IN (${quoted})`, ok: true, list: safe };
102
+ }
103
+
104
+ module.exports = {
105
+ CLIENT_MODE,
106
+ verifyAuth,
107
+ isAdminOnly,
108
+ scopedProjects,
109
+ projectClause,
110
+ };
package/bin/cli.js CHANGED
@@ -6,6 +6,9 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
  const { spawnSync } = require('child_process');
8
8
 
9
+ const platform = require('./platform');
10
+ const scheduler = require('./scheduler');
11
+
9
12
  const DEST = path.join(os.homedir(), 'social-autoposter');
10
13
  const PKG_ROOT = path.join(__dirname, '..');
11
14
  const HOME = os.homedir();
@@ -15,13 +18,26 @@ const COPY_TARGETS = [
15
18
  'scripts',
16
19
  'schema-postgres.sql',
17
20
  'config.example.json',
18
- '.env.example',
21
+ 'requirements.txt',
19
22
  'SKILL.md',
20
23
  'skill',
21
24
  'setup',
22
25
  'browser-agent-configs',
23
26
  ];
24
27
 
28
+ const ENV_TEMPLATE = `# social-autoposter environment variables
29
+ # Fill in your values below.
30
+
31
+ # Moltbook API key (required for Moltbook posting/scanning)
32
+ # Get it from: https://www.moltbook.com/settings/api
33
+ MOLTBOOK_API_KEY=
34
+
35
+ # Neon Postgres connection string. Bring your own Neon DB — apply schema with:
36
+ # psql "$DATABASE_URL" -f schema-postgres.sql
37
+ # Format: postgresql://<user>:<password>@<host>/<db>?sslmode=require
38
+ DATABASE_URL=
39
+ `;
40
+
25
41
  // Never overwrite these user files during update
26
42
  const USER_FILES = new Set(['config.json', '.env', 'SKILL.md']);
27
43
 
@@ -89,13 +105,64 @@ function installBrowserAgentConfigs() {
89
105
  console.log(` browser profile dirs ready -> ${profilesDir}/{${BROWSER_PROFILES.join(',')}}`);
90
106
  }
91
107
 
108
+ // Register the three browser-agent MCP servers with Claude so they show up
109
+ // under user scope (writes to ~/.claude.json). Idempotent: parses the output
110
+ // of `claude mcp list` and only calls `add-json` for missing entries.
111
+ // If the `claude` CLI is not on PATH, prints manual instructions and returns.
112
+ function registerBrowserAgentMcpServers() {
113
+ const configDir = path.join(HOME, '.claude', 'browser-agent-configs');
114
+ const servers = [
115
+ { name: 'twitter-agent', file: path.join(configDir, 'twitter-agent-mcp.json') },
116
+ { name: 'reddit-agent', file: path.join(configDir, 'reddit-agent-mcp.json') },
117
+ { name: 'linkedin-agent', file: path.join(configDir, 'linkedin-agent-mcp.json') },
118
+ ];
119
+
120
+ const claudeBin = spawnSync('claude', ['--version'], { stdio: 'pipe' });
121
+ if (claudeBin.status !== 0) {
122
+ console.log(' claude CLI not on PATH; skipping MCP registration.');
123
+ console.log(' Once Claude Code is installed, register manually with:');
124
+ for (const s of servers) {
125
+ console.log(` claude mcp add-json ${s.name} "$(jq -c .mcpServers['\\"'${s.name}'\\"'] ${s.file})"`);
126
+ }
127
+ return;
128
+ }
129
+
130
+ const list = spawnSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
131
+ const existing = list.status === 0 ? list.stdout : '';
132
+
133
+ let added = 0;
134
+ let skipped = 0;
135
+ for (const s of servers) {
136
+ if (!fs.existsSync(s.file)) {
137
+ console.warn(` MCP config missing: ${s.file}`);
138
+ continue;
139
+ }
140
+ // `claude mcp list` prints one server per line starting with the name.
141
+ // Use a word-boundary check so twitter-agent does not false-match reddit-agent.
142
+ const re = new RegExp(`(^|\\s)${s.name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(:|\\s|$)`, 'm');
143
+ if (re.test(existing)) {
144
+ skipped++;
145
+ continue;
146
+ }
147
+ const tpl = JSON.parse(fs.readFileSync(s.file, 'utf8'));
148
+ const stanza = tpl.mcpServers && tpl.mcpServers[s.name];
149
+ if (!stanza) {
150
+ console.warn(` ${s.file} has no mcpServers.${s.name} stanza; skipping`);
151
+ continue;
152
+ }
153
+ const r = spawnSync('claude', ['mcp', 'add-json', s.name, JSON.stringify(stanza)], { stdio: 'pipe', encoding: 'utf8' });
154
+ if (r.status === 0) {
155
+ added++;
156
+ } else {
157
+ console.warn(` claude mcp add-json ${s.name} failed: ${(r.stderr || r.stdout || '').trim()}`);
158
+ }
159
+ }
160
+ console.log(` MCP servers registered with Claude (added ${added}, already present ${skipped})`);
161
+ }
162
+
92
163
  function generatePlists() {
93
- // Detect PATH for launchd (include node, homebrew, system)
94
164
  const nodeBin = path.dirname(process.execPath);
95
- const pathDirs = new Set([nodeBin, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']);
96
- const launchdPath = [...pathDirs].join(':');
97
-
98
- const plists = [
165
+ const jobs = [
99
166
  {
100
167
  file: 'com.m13v.social-stats.plist',
101
168
  label: 'com.m13v.social-stats',
@@ -105,53 +172,92 @@ function generatePlists() {
105
172
  stdoutLog: `${DEST}/skill/logs/launchd-stats-stdout.log`,
106
173
  stderrLog: `${DEST}/skill/logs/launchd-stats-stderr.log`,
107
174
  },
108
- {
109
- file: 'com.m13v.social-engage.plist',
110
- label: 'com.m13v.social-engage',
111
- script: `${DEST}/skill/engage.sh`,
112
- interval: 21600,
113
- runAtLoad: false,
114
- stdoutLog: `${DEST}/skill/logs/launchd-engage-stdout.log`,
115
- stderrLog: `${DEST}/skill/logs/launchd-engage-stderr.log`,
116
- },
117
175
  ];
118
176
 
119
- const launchdDir = path.join(DEST, 'launchd');
120
- fs.mkdirSync(launchdDir, { recursive: true });
121
-
122
- for (const p of plists) {
123
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
124
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
125
- <plist version="1.0">
126
- <dict>
127
- \t<key>Label</key>
128
- \t<string>${p.label}</string>
129
- \t<key>ProgramArguments</key>
130
- \t<array>
131
- \t\t<string>/bin/bash</string>
132
- \t\t<string>${p.script}</string>
133
- \t</array>
134
- \t<key>StartInterval</key>
135
- \t<integer>${p.interval}</integer>
136
- \t<key>StandardOutPath</key>
137
- \t<string>${p.stdoutLog}</string>
138
- \t<key>StandardErrorPath</key>
139
- \t<string>${p.stderrLog}</string>
140
- \t<key>EnvironmentVariables</key>
141
- \t<dict>
142
- \t\t<key>PATH</key>
143
- \t\t<string>${launchdPath}</string>
144
- \t\t<key>HOME</key>
145
- \t\t<string>${HOME}</string>
146
- \t</dict>
147
- \t<key>RunAtLoad</key>
148
- \t<${p.runAtLoad}/>
149
- </dict>
150
- </plist>
151
- `;
152
- fs.writeFileSync(path.join(launchdDir, p.file), xml);
177
+ const driver = scheduler.driverFor();
178
+ const env = driver.defaultEnv({ home: HOME, nodeBin });
179
+ const kind = platform.scheduler();
180
+ const outDir = path.join(DEST, kind === 'systemd' ? 'systemd' : 'launchd');
181
+ driver.generate({ jobs, outDir, env });
182
+ console.log(` generated ${kind} units at ${outDir}`);
183
+ }
184
+
185
+ // On Linux we translate every shipped launchd plist into a systemd
186
+ // .service + .timer pair at install time. Plists remain the source of truth
187
+ // so the macOS pipeline is untouched; the systemd/ dir is derived.
188
+ function generateSystemdFromPlists() {
189
+ const launchdDriver = scheduler.driverFor('launchd');
190
+ const systemdDriver = scheduler.driverFor('systemd');
191
+ const srcDir = path.join(DEST, 'launchd');
192
+ const outDir = path.join(DEST, 'systemd');
193
+ if (!fs.existsSync(srcDir)) return 0;
194
+ const plists = fs.readdirSync(srcDir).filter(f => f.endsWith('.plist'));
195
+ const nodeBin = path.dirname(process.execPath);
196
+ const env = systemdDriver.defaultEnv({ home: HOME, nodeBin });
197
+
198
+ const jobs = [];
199
+ let skipped = 0;
200
+ for (const f of plists) {
201
+ const xml = fs.readFileSync(path.join(srcDir, f), 'utf8');
202
+ const { label, scriptPath } = launchdDriver.parseUnit(xml);
203
+ if (!label || !scriptPath) { skipped++; continue; }
204
+ const sched = launchdDriver.scheduleFromUnit(xml);
205
+ if (!sched.intervalSecs) {
206
+ console.log(` skip ${f}: calendar schedule not yet translated to OnCalendar`);
207
+ skipped++;
208
+ continue;
209
+ }
210
+ // Plists ship with the publisher's absolute paths baked in. Rebuild
211
+ // paths against the current user's DEST so any user on any host gets
212
+ // correct units without us having to re-ship plists per install target.
213
+ const scriptBase = path.basename(scriptPath);
214
+ const stdoutMatch = (xml.match(/<key>StandardOutPath<\/key>\s*<string>([^<]+)<\/string>/) || [])[1];
215
+ const stderrMatch = (xml.match(/<key>StandardErrorPath<\/key>\s*<string>([^<]+)<\/string>/) || [])[1];
216
+ const shortLabel = label.replace(/^com\.m13v\.social-/, '');
217
+ const stdout = `${DEST}/skill/logs/${stdoutMatch ? path.basename(stdoutMatch) : `launchd-${shortLabel}-stdout.log`}`;
218
+ const stderr = `${DEST}/skill/logs/${stderrMatch ? path.basename(stderrMatch) : `launchd-${shortLabel}-stderr.log`}`;
219
+ const runAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/.test(xml);
220
+ jobs.push({
221
+ file: f,
222
+ label,
223
+ script: `${DEST}/skill/${scriptBase}`,
224
+ interval: sched.intervalSecs,
225
+ runAtLoad,
226
+ stdoutLog: stdout,
227
+ stderrLog: stderr,
228
+ });
229
+ }
230
+ systemdDriver.generate({ jobs, outDir, env });
231
+ console.log(` translated ${jobs.length} launchd plists -> systemd units (skipped ${skipped})`);
232
+ return jobs.length;
233
+ }
234
+
235
+ // Link every DEST/systemd/*.{service,timer} into ~/.config/systemd/user/ and
236
+ // reload the user daemon. Caller is expected to `systemctl --user enable --now
237
+ // <timer>` for each timer they actually want running; this mirrors how macOS
238
+ // setup leaves loading to the user via the SKILL.md wizard.
239
+ function installSystemdUnits() {
240
+ const driver = scheduler.driverFor('systemd');
241
+ const unitDir = path.join(DEST, 'systemd');
242
+ const agentsDir = platform.agentsDir();
243
+ if (!fs.existsSync(unitDir)) return;
244
+ fs.mkdirSync(agentsDir, { recursive: true });
245
+ const services = fs.readdirSync(unitDir).filter(f => f.endsWith('.service'));
246
+ let linked = 0;
247
+ for (const f of services) {
248
+ if (driver.install(path.join(unitDir, f), agentsDir)) linked++;
249
+ }
250
+ const r = spawnSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf8' });
251
+ if (r.status === 0) {
252
+ console.log(` linked ${linked} unit pair(s) into ${agentsDir}; systemctl --user daemon-reload OK`);
253
+ } else {
254
+ console.warn(` linked ${linked} unit pair(s); daemon-reload failed: ${(r.stderr || '').trim()}`);
255
+ }
256
+ const linger = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf8' });
257
+ if (!/Linger=yes/.test(linger.stdout || '')) {
258
+ console.log(' note: run `sudo loginctl enable-linger $USER` so timers fire when nobody is logged in');
153
259
  }
154
- console.log(' generated launchd plists with correct paths');
260
+ console.log(' next: systemctl --user enable --now <timer> for each job you want scheduled');
155
261
  }
156
262
 
157
263
  function init() {
@@ -175,8 +281,17 @@ function init() {
175
281
  // Generate launchd plists with user's actual HOME
176
282
  generatePlists();
177
283
 
284
+ // On Linux, derive systemd units from every plist and link them into
285
+ // ~/.config/systemd/user/. macOS install is unchanged.
286
+ if (platform.scheduler() === 'systemd') {
287
+ generateSystemdFromPlists();
288
+ installSystemdUnits();
289
+ }
290
+
178
291
  // Install browser agent MCP configs + profile dirs (skips existing files)
179
292
  installBrowserAgentConfigs();
293
+ // Register those MCP servers with Claude so they show up in `claude mcp list`.
294
+ registerBrowserAgentMcpServers();
180
295
 
181
296
  // config.json — only if it doesn't exist
182
297
  const configDest = path.join(DEST, 'config.json');
@@ -187,29 +302,18 @@ function init() {
187
302
  console.log(' config.json exists — skipping');
188
303
  }
189
304
 
190
- // .env — only if it doesn't exist
305
+ // .env — only if it doesn't exist. Written from an in-package template so
306
+ // the NPM tarball no longer ships a credential-bearing .env.example file.
191
307
  const envDest = path.join(DEST, '.env');
192
308
  if (!fs.existsSync(envDest)) {
193
- fs.copyFileSync(path.join(PKG_ROOT, '.env.example'), envDest);
194
- console.log(' created .env from template');
309
+ fs.writeFileSync(envDest, ENV_TEMPLATE);
310
+ console.log(' created .env from template (fill in DATABASE_URL and MOLTBOOK_API_KEY)');
195
311
  } else {
196
312
  console.log(' .env exists — skipping');
197
313
  }
198
314
 
199
- // Check psycopg2-binary (required to connect to Neon DB)
200
- const pip3Check = spawnSync('pip3', ['show', 'psycopg2-binary'], { stdio: 'pipe' });
201
- if (pip3Check.status !== 0) {
202
- console.log(' installing psycopg2-binary (required for Neon DB)...');
203
- const pipInstall = spawnSync('pip3', ['install', 'psycopg2-binary', '-q'], { stdio: 'inherit' });
204
- if (pipInstall.status !== 0) {
205
- console.warn(' WARNING: psycopg2-binary install failed — run manually:');
206
- console.warn(' pip3 install psycopg2-binary');
207
- } else {
208
- console.log(' psycopg2-binary installed');
209
- }
210
- } else {
211
- console.log(' psycopg2-binary already installed');
212
- }
315
+ installPythonDeps();
316
+ installEngagementStylesSidecar();
213
317
 
214
318
  // Remove stale skill/SKILL.md if it exists (SKILL.md lives at repo root only)
215
319
  const skillMd = path.join(DEST, 'skill', 'SKILL.md');
@@ -259,8 +363,21 @@ function update() {
259
363
  // Regenerate launchd plists with correct paths
260
364
  generatePlists();
261
365
 
366
+ // Refresh systemd units on Linux so plist changes propagate.
367
+ if (platform.scheduler() === 'systemd') {
368
+ generateSystemdFromPlists();
369
+ installSystemdUnits();
370
+ }
371
+
262
372
  // Top up browser agent configs (won't overwrite user customizations)
263
373
  installBrowserAgentConfigs();
374
+ // Register any newly added MCP servers with Claude (idempotent).
375
+ registerBrowserAgentMcpServers();
376
+
377
+ // Refresh Python deps every update so version-bumps land on existing installs
378
+ // and the candidate-style sidecar gets merged (preserves VM-side candidates).
379
+ installPythonDeps();
380
+ installEngagementStylesSidecar();
264
381
 
265
382
  // Remove stale skill/SKILL.md if it exists (SKILL.md lives at repo root only)
266
383
  const skillMd = path.join(DEST, 'skill', 'SKILL.md');
@@ -281,18 +398,92 @@ function update() {
281
398
  console.log('Update complete. config.json was preserved.');
282
399
  }
283
400
 
401
+ // Install Python deps from requirements.txt (preferred) or fall back to the
402
+ // hardcoded list. Idempotent — pip3 install is a no-op when the package is
403
+ // already at the requested version. Playwright also needs the Chromium
404
+ // browser binary; we run `playwright install chromium` after the pip install.
405
+ function installPythonDeps() {
406
+ const reqPath = path.join(PKG_ROOT, 'requirements.txt');
407
+ const base = fs.existsSync(reqPath)
408
+ ? ['install', '-r', reqPath, '-q']
409
+ : ['install', '-q', 'psycopg2-binary', 'playwright'];
410
+ console.log(' installing Python deps (psycopg2-binary, playwright, ...)');
411
+ // Debian/Ubuntu 23+ ship a PEP 668 marker that blocks pip3 against the
412
+ // system Python without --break-system-packages. Try without first
413
+ // (safer on macOS) and retry with the flag if the marker fires.
414
+ let r = spawnSync('pip3', base, { stdio: 'inherit' });
415
+ if (r.status !== 0) {
416
+ console.log(' retrying with --break-system-packages (PEP 668 environments)');
417
+ r = spawnSync('pip3', [...base, '--break-system-packages'], { stdio: 'inherit' });
418
+ }
419
+ if (r.status !== 0) {
420
+ console.warn(' WARNING: pip3 install failed — run manually:');
421
+ console.warn(` pip3 ${base.join(' ')} --break-system-packages`);
422
+ return;
423
+ }
424
+ // Playwright needs its browser binary downloaded separately. Chromium
425
+ // is the only engine the repo uses today; skip Firefox/WebKit.
426
+ console.log(' installing Playwright Chromium binary (one-time, ~150MB)...');
427
+ const pw = spawnSync('python3', ['-m', 'playwright', 'install', 'chromium'], { stdio: 'inherit' });
428
+ if (pw.status !== 0) {
429
+ console.warn(' WARNING: playwright install chromium failed — run manually:');
430
+ console.warn(' python3 -m playwright install chromium');
431
+ }
432
+ }
433
+
434
+ // Copy the candidate-style sidecar JSON into ~/social-autoposter/scripts/
435
+ // if missing; merge if present so VM-side invented candidates survive
436
+ // across updates. Promoted (status=active) entries from the shipped baseline
437
+ // always win.
438
+ function installEngagementStylesSidecar() {
439
+ const src = path.join(PKG_ROOT, 'scripts', 'engagement_styles_extra.json');
440
+ const dest = path.join(DEST, 'scripts', 'engagement_styles_extra.json');
441
+ if (!fs.existsSync(src)) return;
442
+
443
+ if (!fs.existsSync(dest)) {
444
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
445
+ fs.copyFileSync(src, dest);
446
+ console.log(' installed scripts/engagement_styles_extra.json');
447
+ return;
448
+ }
449
+
450
+ let shipped = {};
451
+ let local = {};
452
+ try { shipped = JSON.parse(fs.readFileSync(src, 'utf8')) || {}; } catch {}
453
+ try { local = JSON.parse(fs.readFileSync(dest, 'utf8')) || {}; } catch {}
454
+
455
+ // Start from local (preserves VM-only candidates), overlay shipped active
456
+ // entries so newly promoted styles always land. Shipped wins on conflict.
457
+ const merged = { ...local };
458
+ for (const [name, entry] of Object.entries(shipped)) {
459
+ merged[name] = entry;
460
+ }
461
+ fs.writeFileSync(dest, JSON.stringify(merged, null, 2) + '\n');
462
+ console.log(' merged scripts/engagement_styles_extra.json (shipped wins on conflict)');
463
+ }
464
+
284
465
  const cmd = process.argv[2];
285
466
  if (cmd === 'init') {
286
467
  init();
287
468
  } else if (cmd === 'update') {
288
469
  update();
470
+ } else if (cmd === 'export-cookies') {
471
+ // Forward to cookie-helper with 'export' + remaining args
472
+ process.argv = [process.argv[0], process.argv[1], 'export', ...process.argv.slice(3)];
473
+ require('./cookie-helper.js');
474
+ } else if (cmd === 'import-cookies') {
475
+ // Forward to cookie-helper with 'import' + remaining args
476
+ process.argv = [process.argv[0], process.argv[1], 'import', ...process.argv.slice(3)];
477
+ require('./cookie-helper.js');
289
478
  } else if (!cmd) {
290
479
  require('./server.js');
291
480
  } else {
292
481
  console.log('social-autoposter — automated social posting for Claude agents');
293
482
  console.log('');
294
483
  console.log('Usage:');
295
- console.log(' npx social-autoposter open the dashboard');
296
- console.log(' npx social-autoposter init first-time setup');
297
- console.log(' npx social-autoposter update update scripts, preserve config');
484
+ console.log(' npx social-autoposter open the dashboard');
485
+ console.log(' npx social-autoposter init first-time setup');
486
+ console.log(' npx social-autoposter update update scripts, preserve config');
487
+ console.log(' npx social-autoposter export-cookies [dir] export browser cookies');
488
+ console.log(' npx social-autoposter import-cookies [dir] import browser cookies');
298
489
  }