thumbgate 0.9.10 → 0.9.11

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 (113) hide show
  1. package/.claude-plugin/README.md +2 -2
  2. package/.claude-plugin/marketplace.json +4 -2
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +115 -312
  6. package/adapters/README.md +1 -1
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +4 -4
  9. package/adapters/mcp/server-stdio.js +61 -1
  10. package/adapters/opencode/opencode.json +4 -2
  11. package/bin/cli.js +156 -8
  12. package/bin/memory.sh +3 -3
  13. package/config/e2e-critical-flows.json +4 -0
  14. package/config/gates/default.json +74 -2
  15. package/config/github-about.json +1 -1
  16. package/config/mcp-allowlists.json +27 -0
  17. package/package.json +22 -5
  18. package/plugins/amp-skill/INSTALL.md +1 -0
  19. package/plugins/amp-skill/SKILL.md +1 -0
  20. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  21. package/plugins/claude-codex-bridge/.mcp.json +4 -2
  22. package/plugins/claude-skill/INSTALL.md +1 -0
  23. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  24. package/plugins/codex-profile/.mcp.json +4 -2
  25. package/plugins/codex-profile/INSTALL.md +1 -1
  26. package/plugins/codex-profile/README.md +1 -1
  27. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  28. package/plugins/cursor-marketplace/README.md +3 -3
  29. package/plugins/cursor-marketplace/mcp.json +3 -1
  30. package/plugins/cursor-marketplace/scripts/gate-check.sh +15 -5
  31. package/plugins/gemini-extension/INSTALL.md +3 -3
  32. package/plugins/opencode-profile/INSTALL.md +1 -1
  33. package/public/dashboard.html +15 -8
  34. package/public/index.html +125 -185
  35. package/public/js/buyer-intent.js +252 -0
  36. package/public/pro.html +1085 -0
  37. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  38. package/scripts/adk-consolidator.js +14 -2
  39. package/scripts/agent-readiness.js +3 -1
  40. package/scripts/agent-security-hardening.js +4 -4
  41. package/scripts/auto-promote-gates.js +2 -0
  42. package/scripts/auto-wire-hooks.js +105 -17
  43. package/scripts/behavioral-extraction.js +2 -6
  44. package/scripts/billing.js +107 -3
  45. package/scripts/budget-guard.js +2 -2
  46. package/scripts/build-metadata.js +14 -0
  47. package/scripts/context-engine.js +1 -0
  48. package/scripts/deploy-policy.js +3 -17
  49. package/scripts/dpo-optimizer.js +3 -6
  50. package/scripts/ensure-repo-bootstrap.js +129 -0
  51. package/scripts/export-dpo-pairs.js +2 -3
  52. package/scripts/export-kto-pairs.js +3 -4
  53. package/scripts/export-training.js +8 -6
  54. package/scripts/feedback-attribution.js +23 -11
  55. package/scripts/feedback-loop.js +40 -2
  56. package/scripts/feedback-to-rules.js +2 -1
  57. package/scripts/filesystem-search.js +3 -2
  58. package/scripts/gates-engine.js +760 -29
  59. package/scripts/generate-pretool-hook.sh +0 -0
  60. package/scripts/gtm-revenue-loop.js +20 -1
  61. package/scripts/hook-auto-capture.sh +8 -3
  62. package/scripts/hook-runtime.js +89 -0
  63. package/scripts/hook-stop-self-score.sh +3 -3
  64. package/scripts/hook-thumbgate-cache-updater.js +99 -38
  65. package/scripts/hosted-config.js +4 -16
  66. package/scripts/hybrid-feedback-context.js +54 -14
  67. package/scripts/install-mcp.js +13 -0
  68. package/scripts/intent-router.js +2 -2
  69. package/scripts/license.js +52 -14
  70. package/scripts/local-model-profile.js +3 -2
  71. package/scripts/mcp-config.js +68 -6
  72. package/scripts/meta-policy.js +4 -8
  73. package/scripts/money-watcher.js +166 -16
  74. package/scripts/obsidian-export.js +1 -0
  75. package/scripts/operational-integrity.js +480 -0
  76. package/scripts/post-everywhere.js +7 -12
  77. package/scripts/pr-manager.js +14 -11
  78. package/scripts/profile-router.js +2 -0
  79. package/scripts/prompt-dlp.js +1 -0
  80. package/scripts/publish-decision.js +10 -0
  81. package/scripts/published-cli.js +34 -0
  82. package/scripts/risk-scorer.js +3 -2
  83. package/scripts/rlhf_session_start.sh +32 -0
  84. package/scripts/skill-quality-tracker.js +3 -5
  85. package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
  86. package/scripts/social-analytics/db/social-analytics.db-wal +0 -0
  87. package/scripts/social-analytics/engagement-audit.js +202 -0
  88. package/scripts/social-analytics/instagram-thumbgate-post.js +45 -7
  89. package/scripts/social-analytics/install-growth-automation.js +114 -0
  90. package/scripts/social-analytics/load-env.js +46 -0
  91. package/scripts/social-analytics/poll-all.js +3 -18
  92. package/scripts/social-analytics/pollers/zernio.js +3 -0
  93. package/scripts/social-analytics/publish-instagram-thumbgate.js +22 -3
  94. package/scripts/social-analytics/publish-thumbgate-launch.js +316 -0
  95. package/scripts/social-analytics/publishers/reddit.js +7 -12
  96. package/scripts/social-analytics/publishers/zernio.js +210 -22
  97. package/scripts/social-analytics/reconcile-thumbgate-campaign.js +165 -0
  98. package/scripts/social-analytics/schedule-thumbgate-campaign.js +275 -0
  99. package/scripts/social-analytics/sync-launch-assets.js +185 -0
  100. package/scripts/social-post-hourly.js +185 -0
  101. package/scripts/social-quality-gate.js +119 -3
  102. package/scripts/social-reply-monitor.js +148 -32
  103. package/scripts/statusline-cache-path.js +27 -0
  104. package/scripts/statusline-meta.js +22 -0
  105. package/scripts/statusline.sh +24 -32
  106. package/scripts/sync-version.js +11 -3
  107. package/scripts/test-coverage.js +20 -13
  108. package/scripts/tool-registry.js +97 -0
  109. package/scripts/train_from_feedback.py +32 -9
  110. package/scripts/validate-feedback.js +3 -2
  111. package/scripts/vector-store.js +2 -3
  112. package/scripts/verify-obsidian-setup.sh +3 -3
  113. package/src/api/server.js +281 -33
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { buildUTMLink } = require('./utm');
5
+ const { publishInstagramThumbGate } = require('./publish-instagram-thumbgate');
6
+ const {
7
+ getConnectedAccounts,
8
+ groupAccountsByPlatform,
9
+ publishPost,
10
+ schedulePost,
11
+ } = require('./publishers/zernio');
12
+ const { THUMBGATE_CAPTION } = require('./instagram-thumbgate-post');
13
+ const { resolveHostedBillingConfig } = require('../hosted-config');
14
+
15
+ const APP_ORIGIN = resolveHostedBillingConfig({
16
+ requestOrigin: 'https://thumbgate-production.up.railway.app',
17
+ }).appOrigin;
18
+ const DEFAULT_TIMEZONE = 'America/New_York';
19
+ const LAUNCH_CAMPAIGN = 'first_customer_push';
20
+ const DEFAULT_LAUNCH_PLATFORMS = ['twitter', 'linkedin', 'instagram'];
21
+
22
+ function parseArgs(argv = []) {
23
+ const options = {
24
+ dryRun: false,
25
+ platforms: [],
26
+ schedule: '',
27
+ timezone: DEFAULT_TIMEZONE,
28
+ };
29
+
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const token = argv[index];
32
+ if (token === '--dry-run') {
33
+ options.dryRun = true;
34
+ continue;
35
+ }
36
+
37
+ if (token.startsWith('--platforms=')) {
38
+ options.platforms = token
39
+ .slice('--platforms='.length)
40
+ .split(',')
41
+ .map((platform) => platform.trim())
42
+ .filter(Boolean);
43
+ continue;
44
+ }
45
+
46
+ if (token === '--platforms' && argv[index + 1]) {
47
+ options.platforms = String(argv[index + 1])
48
+ .split(',')
49
+ .map((platform) => platform.trim())
50
+ .filter(Boolean);
51
+ index += 1;
52
+ continue;
53
+ }
54
+
55
+ if (token.startsWith('--schedule=')) {
56
+ options.schedule = token.slice('--schedule='.length).trim();
57
+ continue;
58
+ }
59
+
60
+ if (token === '--schedule' && argv[index + 1]) {
61
+ options.schedule = String(argv[index + 1]).trim();
62
+ index += 1;
63
+ continue;
64
+ }
65
+
66
+ if (token.startsWith('--timezone=')) {
67
+ options.timezone = token.slice('--timezone='.length).trim() || DEFAULT_TIMEZONE;
68
+ continue;
69
+ }
70
+
71
+ if (token === '--timezone' && argv[index + 1]) {
72
+ options.timezone = String(argv[index + 1]).trim() || DEFAULT_TIMEZONE;
73
+ index += 1;
74
+ }
75
+ }
76
+
77
+ return options;
78
+ }
79
+
80
+ function buildLandingUrl(platform, content) {
81
+ return buildUTMLink(`${APP_ORIGIN}/`, {
82
+ source: platform,
83
+ medium: 'organic_social',
84
+ campaign: LAUNCH_CAMPAIGN,
85
+ content,
86
+ });
87
+ }
88
+
89
+ function buildPlatformPost(platform) {
90
+ const normalized = String(platform || '').trim().toLowerCase();
91
+
92
+ if (normalized === 'twitter' || normalized === 'x') {
93
+ return [
94
+ 'Claude Code kept repeating the same mistakes across sessions.',
95
+ 'ThumbGate turns thumbs-down feedback into a prevention rule that blocks the same pattern next time.',
96
+ 'Local-first. Free path. Pro trial.',
97
+ buildLandingUrl('x', 'launch_post_twitter'),
98
+ ].join(' ');
99
+ }
100
+
101
+ if (normalized === 'linkedin') {
102
+ return [
103
+ 'AI coding agents do not reliably learn from your repo-level pain.',
104
+ 'That is the problem I kept hitting with Claude Code and Cursor: the same broken config, import, or workflow mistake would come back in the next session.',
105
+ 'ThumbGate turns thumbs-down feedback into a prevention rule and blocks the same pattern before the next tool call lands.',
106
+ 'Local-first. Free path. Pro adds the personal dashboard, DPO export, and a gate debugger.',
107
+ buildLandingUrl('linkedin', 'launch_post_linkedin'),
108
+ ].join(' ');
109
+ }
110
+
111
+ if (normalized === 'instagram') {
112
+ return `${THUMBGATE_CAPTION}\n\n${buildLandingUrl('instagram', 'launch_post_instagram')}`;
113
+ }
114
+
115
+ if (normalized === 'reddit') {
116
+ return [
117
+ 'I built ThumbGate after watching Claude Code repeat the same repo mistakes across sessions.',
118
+ 'It turns thumbs-down feedback into a prevention rule so the same pattern gets blocked next time.',
119
+ 'Free local path, no cloud account required.',
120
+ buildLandingUrl('reddit', 'launch_post_reddit'),
121
+ ].join(' ');
122
+ }
123
+
124
+ return [
125
+ 'ThumbGate turns AI coding-agent feedback into enforced prevention rules so the same mistake gets blocked in the next session.',
126
+ 'Local-first. Free path. Pro adds the personal dashboard and DPO export.',
127
+ buildLandingUrl(normalized || 'zernio', `launch_post_${normalized || 'generic'}`),
128
+ ].join(' ');
129
+ }
130
+
131
+ function buildCampaignEntries() {
132
+ return [
133
+ {
134
+ slug: 'proof_pack',
135
+ posts: {
136
+ twitter: [
137
+ 'AI coding agents do not need more hype. They need proof-backed workflow hardening.',
138
+ 'ThumbGate turns thumbs-down feedback into a prevention rule that blocks the same mistake next session.',
139
+ 'Proof pack:',
140
+ buildLandingUrl('x', 'campaign_proof_pack'),
141
+ ].join(' '),
142
+ linkedin: [
143
+ 'Workflow hardening beats generic AI hype.',
144
+ 'ThumbGate captures failure signals, promotes them into prevention rules, and blocks the same bad pattern before the next tool call executes.',
145
+ 'This is about one workflow becoming safe enough to ship, not abstract "agent memory."',
146
+ buildLandingUrl('linkedin', 'campaign_proof_pack'),
147
+ ].join(' '),
148
+ instagram: `${THUMBGATE_CAPTION}\n\nProof-backed workflow hardening.\n\n${buildLandingUrl('instagram', 'campaign_proof_pack')}`,
149
+ },
150
+ },
151
+ {
152
+ slug: 'free_local',
153
+ posts: {
154
+ twitter: [
155
+ 'The free path is the point.',
156
+ 'ThumbGate runs local-first, keeps lesson state in .thumbgate, and blocks repeated coding-agent mistakes without a cloud account.',
157
+ buildLandingUrl('x', 'campaign_free_local'),
158
+ ].join(' '),
159
+ linkedin: [
160
+ 'Most AI tooling tries to sell a hosted layer first. ThumbGate does not.',
161
+ 'The free local path gives you feedback capture, prevention rules, and blocking on your machine. Pro adds the personal dashboard and exports when the workflow is already valuable.',
162
+ buildLandingUrl('linkedin', 'campaign_free_local'),
163
+ ].join(' '),
164
+ instagram: [
165
+ 'Your AI coding agent forgets everything between sessions.',
166
+ 'ThumbGate keeps the feedback loop local, durable, and enforceable.',
167
+ buildLandingUrl('instagram', 'campaign_free_local'),
168
+ ].join('\n\n'),
169
+ },
170
+ },
171
+ {
172
+ slug: 'checkout_path',
173
+ posts: {
174
+ twitter: [
175
+ 'If your agent repeats the same repo mistake every week, the fix is not another prompt.',
176
+ 'ThumbGate blocks known-bad patterns before the next tool call lands.',
177
+ 'Free local path, Pro trial here:',
178
+ buildLandingUrl('x', 'campaign_checkout_path'),
179
+ ].join(' '),
180
+ linkedin: [
181
+ 'Repeated agent mistakes are a systems problem, not a prompt-writing problem.',
182
+ 'ThumbGate turns explicit feedback into prevention rules and gives individual operators a paid path when they want the dashboard, exports, and gate debugger.',
183
+ buildLandingUrl('linkedin', 'campaign_checkout_path'),
184
+ ].join(' '),
185
+ instagram: [
186
+ 'ThumbGate turns thumbs-down feedback into a prevention rule.',
187
+ 'Next session, the same mistake gets blocked.',
188
+ buildLandingUrl('instagram', 'campaign_checkout_path'),
189
+ ].join('\n\n'),
190
+ },
191
+ },
192
+ ];
193
+ }
194
+
195
+ function defaultCampaignSchedule(now = new Date()) {
196
+ const target = new Date(now.getTime());
197
+ target.setDate(target.getDate() + 1);
198
+ const year = target.getFullYear();
199
+ const month = String(target.getMonth() + 1).padStart(2, '0');
200
+ const day = String(target.getDate()).padStart(2, '0');
201
+ return [
202
+ `${year}-${month}-${day}T10:15:00-04:00`,
203
+ `${year}-${month}-${day}T14:30:00-04:00`,
204
+ `${year}-${month}-${day}T18:45:00-04:00`,
205
+ ];
206
+ }
207
+
208
+ async function publishLaunchCampaign(options = {}, publisher = {}) {
209
+ const api = {
210
+ getConnectedAccounts: publisher.getConnectedAccounts || getConnectedAccounts,
211
+ groupAccountsByPlatform: publisher.groupAccountsByPlatform || groupAccountsByPlatform,
212
+ publishPost: publisher.publishPost || publishPost,
213
+ schedulePost: publisher.schedulePost || schedulePost,
214
+ publishInstagramThumbGate: publisher.publishInstagramThumbGate || publishInstagramThumbGate,
215
+ };
216
+
217
+ const platforms = Array.isArray(options.platforms) && options.platforms.length > 0
218
+ ? options.platforms
219
+ : DEFAULT_LAUNCH_PLATFORMS;
220
+ const schedule = String(options.schedule || '').trim();
221
+ const timezone = String(options.timezone || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE;
222
+ const accounts = await api.getConnectedAccounts();
223
+ const groupedAccounts = api.groupAccountsByPlatform(accounts);
224
+ const results = {
225
+ dryRun: options.dryRun === true,
226
+ platforms,
227
+ previews: [],
228
+ published: [],
229
+ scheduled: [],
230
+ skipped: [],
231
+ errors: [],
232
+ };
233
+
234
+ for (const platform of platforms) {
235
+ const normalizedPlatform = String(platform || '').trim().toLowerCase();
236
+ const platformAccounts = groupedAccounts.get(normalizedPlatform) || [];
237
+ if (platformAccounts.length === 0) {
238
+ results.skipped.push({ platform: normalizedPlatform, reason: 'not_connected' });
239
+ continue;
240
+ }
241
+
242
+ const content = buildPlatformPost(normalizedPlatform);
243
+ results.previews.push({
244
+ platform: normalizedPlatform,
245
+ content,
246
+ accountCount: platformAccounts.length,
247
+ });
248
+
249
+ if (results.dryRun) {
250
+ continue;
251
+ }
252
+
253
+ const utm = {
254
+ source: normalizedPlatform === 'twitter' ? 'x' : normalizedPlatform,
255
+ medium: 'organic_social',
256
+ campaign: LAUNCH_CAMPAIGN,
257
+ };
258
+
259
+ try {
260
+ if (normalizedPlatform === 'instagram') {
261
+ if (schedule) {
262
+ results.skipped.push({ platform: normalizedPlatform, reason: 'schedule_not_supported_for_instagram_launch' });
263
+ continue;
264
+ }
265
+
266
+ const instagramResult = await api.publishInstagramThumbGate({ caption: content });
267
+ results.published.push({ platform: normalizedPlatform, result: instagramResult });
268
+ continue;
269
+ }
270
+
271
+ if (schedule) {
272
+ const scheduledResult = await api.schedulePost(content, platformAccounts, schedule, timezone, { utm });
273
+ results.scheduled.push({ platform: normalizedPlatform, result: scheduledResult });
274
+ } else {
275
+ const publishResult = await api.publishPost(content, platformAccounts, { utm });
276
+ results.published.push({ platform: normalizedPlatform, result: publishResult });
277
+ }
278
+ } catch (error) {
279
+ results.errors.push({
280
+ platform: normalizedPlatform,
281
+ error: error && error.message ? error.message : String(error),
282
+ });
283
+ }
284
+ }
285
+
286
+ return results;
287
+ }
288
+
289
+ async function main() {
290
+ const options = parseArgs(process.argv.slice(2));
291
+ const results = await publishLaunchCampaign(options);
292
+ process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
293
+ if (results.errors.length > 0) {
294
+ process.exitCode = 1;
295
+ }
296
+ }
297
+
298
+ if (require.main === module) {
299
+ main().catch((error) => {
300
+ console.error(error && error.message ? error.message : error);
301
+ process.exit(1);
302
+ });
303
+ }
304
+
305
+ module.exports = {
306
+ APP_ORIGIN,
307
+ DEFAULT_LAUNCH_PLATFORMS,
308
+ DEFAULT_TIMEZONE,
309
+ LAUNCH_CAMPAIGN,
310
+ buildCampaignEntries,
311
+ buildLandingUrl,
312
+ buildPlatformPost,
313
+ defaultCampaignSchedule,
314
+ parseArgs,
315
+ publishLaunchCampaign,
316
+ };
@@ -257,24 +257,19 @@ async function submitComment(token, userAgent, { parentId, text }) {
257
257
  // ---------------------------------------------------------------------------
258
258
 
259
259
  /**
260
- * Build the standard follow-up comment for a Reddit post.
261
- * This comment contains the trial CTA and source code link.
260
+ * Build a follow-up comment for a Reddit post.
261
+ * Kept minimal and non-promotional to avoid spam flags.
262
+ * Reddit communities aggressively downvote/ban promotional CTAs.
262
263
  *
263
- * @param {string} subreddit - The subreddit name (used for UTM tracking)
264
- * @param {string} [utmContent] - Optional UTM content tag (defaults to subreddit name)
264
+ * @param {string} subreddit - The subreddit name
265
+ * @param {string} [utmContent] - Optional UTM content tag
265
266
  * @returns {string} The follow-up comment text
266
267
  */
267
268
  function buildFollowUpComment(subreddit, utmContent) {
268
- const content = utmContent || `${subreddit}_post`;
269
- const trialUrl = `https://thumbgate-production.up.railway.app/?utm_source=reddit&utm_medium=organic_social&utm_campaign=reddit_followup_comment&utm_content=${encodeURIComponent(content)}&community=${encodeURIComponent(subreddit)}`;
270
269
  return [
271
- 'The problem: AI coding agents repeat the same mistakes every session. You correct a force-push, it does it again tomorrow. Prompt rules get ignored after context compaction.',
270
+ 'Happy to answer questions about the implementation.',
272
271
  '',
273
- 'ThumbGate fixes this with enforcement, not memory. You give a thumbs-down, it auto-generates a prevention rule, and a gate physically blocks the agent from repeating that action. Thumbs-up reinforces good behavior.',
274
- '',
275
- `Try free for 7 days (no credit card, 2-minute setup): ${trialUrl}`,
276
- '',
277
- 'Source code (MIT licensed): https://github.com/IgorGanapolsky/ThumbGate',
272
+ 'Source code (MIT): https://github.com/IgorGanapolsky/ThumbGate',
278
273
  '',
279
274
  'Disclosure: I built this.',
280
275
  ].join('\n');
@@ -8,12 +8,17 @@
8
8
  * ZERNIO_API_KEY — Bearer token for https://zernio.com/api/v1
9
9
  */
10
10
 
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
11
13
  const { tagUrlsInText } = require('../utm');
14
+ const { loadLocalEnv } = require('../load-env');
12
15
 
13
16
  const ZERNIO_UTM = { source: 'zernio', medium: 'social', campaign: 'organic' };
14
17
 
15
18
  const ZERNIO_BASE = 'https://zernio.com/api/v1';
16
19
 
20
+ loadLocalEnv();
21
+
17
22
  function requireApiKey() {
18
23
  const key = process.env.ZERNIO_API_KEY;
19
24
  if (!key) {
@@ -22,6 +27,82 @@ function requireApiKey() {
22
27
  return key;
23
28
  }
24
29
 
30
+ function resolveAccountId(account) {
31
+ if (!account || typeof account !== 'object') {
32
+ return '';
33
+ }
34
+ return String(account.accountId || account._id || account.id || '').trim();
35
+ }
36
+
37
+ function normalizeAccount(account) {
38
+ if (!account || typeof account !== 'object') {
39
+ return null;
40
+ }
41
+ const platform = String(account.platform || '').trim();
42
+ const accountId = resolveAccountId(account);
43
+ return {
44
+ ...account,
45
+ platform,
46
+ accountId,
47
+ };
48
+ }
49
+
50
+ function normalizePlatforms(platforms) {
51
+ return platforms
52
+ .map(normalizeAccount)
53
+ .filter(Boolean)
54
+ .map((platform) => ({
55
+ platform: platform.platform,
56
+ accountId: platform.accountId,
57
+ }));
58
+ }
59
+
60
+ function groupAccountsByPlatform(accounts) {
61
+ const groups = new Map();
62
+ for (const account of accounts) {
63
+ if (!account || !account.platform || !account.accountId) {
64
+ continue;
65
+ }
66
+ const existing = groups.get(account.platform) || [];
67
+ existing.push({
68
+ platform: account.platform,
69
+ accountId: account.accountId,
70
+ });
71
+ groups.set(account.platform, existing);
72
+ }
73
+ return groups;
74
+ }
75
+
76
+ function inferContentType(filePath) {
77
+ const ext = path.extname(String(filePath || '')).toLowerCase();
78
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
79
+ if (ext === '.png') return 'image/png';
80
+ if (ext === '.webp') return 'image/webp';
81
+ if (ext === '.gif') return 'image/gif';
82
+ if (ext === '.mp4') return 'video/mp4';
83
+ if (ext === '.mov') return 'video/quicktime';
84
+ if (ext === '.webm') return 'video/webm';
85
+ return 'application/octet-stream';
86
+ }
87
+
88
+ function inferMediaType(contentType) {
89
+ if (String(contentType || '').startsWith('video/')) {
90
+ return 'video';
91
+ }
92
+ return 'image';
93
+ }
94
+
95
+ function normalizePostResult(payload) {
96
+ const data = payload && typeof payload === 'object' ? (payload.data ?? payload) : {};
97
+ const post = data.post && typeof data.post === 'object' ? data.post : null;
98
+ const id = data.id || data._id || post?._id || post?.id || null;
99
+ return {
100
+ ...data,
101
+ id,
102
+ post: post || data.post,
103
+ };
104
+ }
105
+
25
106
  async function zernioFetch(method, endpoint, body = null) {
26
107
  const apiKey = requireApiKey();
27
108
  const url = `${ZERNIO_BASE}${endpoint}`;
@@ -48,20 +129,92 @@ async function zernioFetch(method, endpoint, body = null) {
48
129
  return res.json();
49
130
  }
50
131
 
132
+ async function listPosts(options = {}) {
133
+ const query = new URLSearchParams();
134
+ if (options.limit) query.set('limit', String(options.limit));
135
+ if (options.page) query.set('page', String(options.page));
136
+ if (options.status) query.set('status', String(options.status));
137
+
138
+ const suffix = query.toString() ? `?${query.toString()}` : '';
139
+ const json = await zernioFetch('GET', `/posts${suffix}`);
140
+ return Array.isArray(json.posts) ? json.posts : (json.data?.posts || json.data || []);
141
+ }
142
+
143
+ async function deletePost(postId) {
144
+ if (!postId) throw new Error('deletePost: postId is required');
145
+ const json = await zernioFetch('DELETE', `/posts/${encodeURIComponent(String(postId).trim())}`);
146
+ return json.data ?? json;
147
+ }
148
+
149
+ async function requestMediaPresign(filename, contentType, size) {
150
+ if (!filename) throw new Error('requestMediaPresign: filename is required');
151
+ if (!contentType) throw new Error('requestMediaPresign: contentType is required');
152
+
153
+ const json = await zernioFetch('POST', '/media/presign', {
154
+ filename,
155
+ contentType,
156
+ size,
157
+ });
158
+
159
+ return json.data ?? json;
160
+ }
161
+
162
+ async function uploadLocalMedia(filePath, options = {}) {
163
+ const resolvedPath = path.resolve(String(filePath || ''));
164
+ if (!fs.existsSync(resolvedPath)) {
165
+ throw new Error(`uploadLocalMedia: file not found at ${resolvedPath}`);
166
+ }
167
+
168
+ const stats = fs.statSync(resolvedPath);
169
+ const filename = options.filename || path.basename(resolvedPath);
170
+ const contentType = options.contentType || inferContentType(resolvedPath);
171
+ const presign = await requestMediaPresign(filename, contentType, stats.size);
172
+
173
+ if (!presign.uploadUrl || !presign.publicUrl) {
174
+ throw new Error('uploadLocalMedia: presign response missing uploadUrl or publicUrl');
175
+ }
176
+
177
+ const uploadResponse = await fetch(presign.uploadUrl, {
178
+ method: 'PUT',
179
+ headers: {
180
+ 'Content-Type': contentType,
181
+ },
182
+ body: fs.readFileSync(resolvedPath),
183
+ });
184
+
185
+ if (!uploadResponse.ok) {
186
+ const errorText = await uploadResponse.text().catch(() => '');
187
+ throw new Error(`uploadLocalMedia: upload failed with ${uploadResponse.status}: ${errorText}`);
188
+ }
189
+
190
+ return {
191
+ contentType,
192
+ key: presign.key || '',
193
+ size: stats.size,
194
+ type: presign.type || inferMediaType(contentType),
195
+ url: presign.publicUrl,
196
+ };
197
+ }
198
+
51
199
  /**
52
200
  * Publishes a post immediately to one or more platforms.
53
201
  * @param {string} content
54
202
  * @param {Array<{platform: string, accountId: string}>} platforms
55
203
  * @returns {Promise<object>}
56
204
  */
57
- async function publishPost(content, platforms) {
205
+ async function publishPost(content, platforms, options = {}) {
58
206
  if (!content) throw new Error('publishPost: content is required');
59
207
  if (!Array.isArray(platforms) || platforms.length === 0) {
60
208
  throw new Error('publishPost: platforms must be a non-empty array');
61
209
  }
62
210
 
211
+ const normalizedPlatforms = normalizePlatforms(platforms);
212
+ if (normalizedPlatforms.length === 0 || normalizedPlatforms.some((entry) => !entry.platform || !entry.accountId)) {
213
+ throw new Error('publishPost: each platform entry requires platform and accountId');
214
+ }
215
+
63
216
  // Tag trackable URLs with Zernio UTM parameters before publishing
64
- content = tagUrlsInText(content, ZERNIO_UTM);
217
+ content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
65
218
 
66
219
  const qualityGate = require('../../social-quality-gate');
67
220
  const gateResult = qualityGate.gatePost(content);
@@ -71,15 +224,17 @@ async function publishPost(content, platforms) {
71
224
  return { blocked: true, reasons: gateResult.findings };
72
225
  }
73
226
 
74
- console.log(`[zernio:publisher] Publishing to ${platforms.length} platform(s): ${platforms.map((p) => p.platform).join(', ')}`);
227
+ console.log(`[zernio:publisher] Publishing to ${normalizedPlatforms.length} platform(s): ${normalizedPlatforms.map((p) => p.platform).join(', ')}`);
75
228
 
76
229
  const json = await zernioFetch('POST', '/posts', {
77
230
  content,
231
+ firstComment: options.firstComment,
232
+ mediaItems: options.mediaItems,
78
233
  publishNow: true,
79
- platforms,
234
+ platforms: normalizedPlatforms,
80
235
  });
81
236
 
82
- const data = json.data ?? json;
237
+ const data = normalizePostResult(json);
83
238
  console.log(`[zernio:publisher] Post published. id=${data.id ?? 'unknown'}`);
84
239
  return data;
85
240
  }
@@ -92,7 +247,7 @@ async function publishPost(content, platforms) {
92
247
  * @param {string} timezone IANA timezone string
93
248
  * @returns {Promise<object>}
94
249
  */
95
- async function schedulePost(content, platforms, scheduledFor, timezone) {
250
+ async function schedulePost(content, platforms, scheduledFor, timezone, options = {}) {
96
251
  if (!content) throw new Error('schedulePost: content is required');
97
252
  if (!Array.isArray(platforms) || platforms.length === 0) {
98
253
  throw new Error('schedulePost: platforms must be a non-empty array');
@@ -100,17 +255,26 @@ async function schedulePost(content, platforms, scheduledFor, timezone) {
100
255
  if (!scheduledFor) throw new Error('schedulePost: scheduledFor is required');
101
256
  if (!timezone) throw new Error('schedulePost: timezone is required');
102
257
 
103
- console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${platforms.length} platform(s)`);
258
+ const normalizedPlatforms = normalizePlatforms(platforms);
259
+ if (normalizedPlatforms.length === 0 || normalizedPlatforms.some((entry) => !entry.platform || !entry.accountId)) {
260
+ throw new Error('schedulePost: each platform entry requires platform and accountId');
261
+ }
262
+
263
+ content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
264
+
265
+ console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${normalizedPlatforms.length} platform(s)`);
104
266
 
105
267
  const json = await zernioFetch('POST', '/posts', {
106
268
  content,
269
+ firstComment: options.firstComment,
270
+ mediaItems: options.mediaItems,
107
271
  publishNow: false,
108
- platforms,
272
+ platforms: normalizedPlatforms,
109
273
  scheduledFor,
110
274
  timezone,
111
275
  });
112
276
 
113
- const data = json.data ?? json;
277
+ const data = normalizePostResult(json);
114
278
  console.log(`[zernio:publisher] Post scheduled. id=${data.id ?? 'unknown'}`);
115
279
  return data;
116
280
  }
@@ -123,7 +287,9 @@ async function getConnectedAccounts() {
123
287
  console.log('[zernio:publisher] Fetching connected accounts');
124
288
 
125
289
  const json = await zernioFetch('GET', '/accounts');
126
- const accounts = Array.isArray(json) ? json : (json.data ?? json.accounts ?? []);
290
+ const accounts = (Array.isArray(json) ? json : (json.data ?? json.accounts ?? []))
291
+ .map(normalizeAccount)
292
+ .filter((account) => account && account.platform && account.accountId);
127
293
 
128
294
  console.log(`[zernio:publisher] ${accounts.length} connected account(s) found`);
129
295
  return accounts;
@@ -134,7 +300,7 @@ async function getConnectedAccounts() {
134
300
  * @param {string} content
135
301
  * @returns {Promise<{ published: object[], errors: object[] }>}
136
302
  */
137
- async function publishToAllPlatforms(content) {
303
+ async function publishToAllPlatforms(content, options = {}) {
138
304
  if (!content) throw new Error('publishToAllPlatforms: content is required');
139
305
 
140
306
  console.log('[zernio:publisher] Fetching all connected accounts for bulk publish');
@@ -145,20 +311,31 @@ async function publishToAllPlatforms(content) {
145
311
  return { published: [], errors: [] };
146
312
  }
147
313
 
148
- const platforms = accounts.map((acc) => ({
149
- platform: acc.platform,
150
- accountId: acc.accountId,
151
- }));
152
-
153
314
  const published = [];
154
315
  const errors = [];
316
+ const requestedPlatforms = Array.isArray(options.platforms) && options.platforms.length > 0
317
+ ? new Set(options.platforms.map((platform) => String(platform || '').trim()).filter(Boolean))
318
+ : null;
319
+ const groupedAccounts = groupAccountsByPlatform(accounts);
320
+
321
+ for (const [platform, platformAccounts] of groupedAccounts.entries()) {
322
+ if (requestedPlatforms && !requestedPlatforms.has(platform)) {
323
+ continue;
324
+ }
155
325
 
156
- try {
157
- const result = await publishPost(content, platforms);
158
- published.push(result);
159
- } catch (err) {
160
- console.error(`[zernio:publisher] Bulk publish failed: ${err.message}`);
161
- errors.push({ error: err.message, platforms: platforms.map((p) => p.platform) });
326
+ try {
327
+ const result = await publishPost(content, platformAccounts, {
328
+ utm: {
329
+ source: platform,
330
+ medium: options.medium || ZERNIO_UTM.medium,
331
+ campaign: options.campaign || ZERNIO_UTM.campaign,
332
+ },
333
+ });
334
+ published.push({ platform, result });
335
+ } catch (err) {
336
+ console.error(`[zernio:publisher] Bulk publish failed for ${platform}: ${err.message}`);
337
+ errors.push({ error: err.message, platform });
338
+ }
162
339
  }
163
340
 
164
341
  console.log(`[zernio:publisher] Bulk publish complete. published=${published.length} errors=${errors.length}`);
@@ -166,10 +343,21 @@ async function publishToAllPlatforms(content) {
166
343
  }
167
344
 
168
345
  module.exports = {
346
+ deletePost,
347
+ listPosts,
169
348
  publishPost,
170
349
  schedulePost,
171
350
  publishToAllPlatforms,
172
351
  getConnectedAccounts,
352
+ groupAccountsByPlatform,
353
+ inferContentType,
354
+ inferMediaType,
355
+ normalizeAccount,
356
+ normalizePlatforms,
357
+ normalizePostResult,
358
+ requestMediaPresign,
359
+ resolveAccountId,
360
+ uploadLocalMedia,
173
361
  };
174
362
 
175
363
  if (require.main === module) {