@ldraney/mcp-linkedin 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Lucas Draney
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # MCP LinkedIn
2
+
3
+ **Post to LinkedIn from Claude Desktop.**
4
+
5
+ Install in 30 seconds. Authenticate once. Post forever.
6
+
7
+ ## Quick Start
8
+
9
+ ### Option A: One-Click Install (Recommended)
10
+ 1. **Download** [mcp-linkedin.mcpb](https://github.com/ldraney/mcp-linkedin/releases/latest)
11
+ 2. **Install** in Claude Desktop: Settings → Extensions → Install Extension
12
+ 3. **Authorize** when prompted (one-time LinkedIn OAuth)
13
+
14
+ ### Option B: Command Line
15
+ ```bash
16
+ npx @ldraney/mcp-linkedin
17
+ ```
18
+ Then add to your Claude Desktop config and authorize.
19
+
20
+ That's it! Ask Claude to post to LinkedIn.
21
+
22
+ ## What You Can Do
23
+
24
+ Ask Claude to:
25
+
26
+ - *"Post about my new GitHub project with a link"*
27
+ - *"Create a poll asking my network about AI trends"*
28
+ - *"Schedule a post for tomorrow at 9am"*
29
+ - *"Upload this PDF and share it with my network"*
30
+
31
+ ### 20 Tools Available
32
+
33
+ | Category | Tools |
34
+ |----------|-------|
35
+ | **Posting** | Text, links, images, videos, documents, polls |
36
+ | **Multi-media** | Up to 20 images per post |
37
+ | **Scheduling** | Schedule posts for future publication |
38
+ | **Engagement** | Comment and react to posts |
39
+ | **Management** | Edit, delete, list your posts |
40
+
41
+ ## How It Works
42
+
43
+ ```
44
+ npm registry (@ldraney/mcp-linkedin)
45
+
46
+ ├─► Developers: npx @ldraney/mcp-linkedin
47
+
48
+ └─► Everyone: Download .mcpb → Install via Extensions
49
+
50
+ └─► Both run the same npm package
51
+
52
+ └─► First use triggers OAuth
53
+
54
+ └─► Tokens stored locally
55
+
56
+ └─► Post to LinkedIn!
57
+ ```
58
+
59
+ Your credentials stay on your machine. Our backend only handles the OAuth handshake.
60
+
61
+ ## Requirements
62
+
63
+ - [Claude Desktop](https://claude.ai/download) installed
64
+ - LinkedIn account
65
+
66
+ ## Documentation
67
+
68
+ - [User Story](./docs/user-story.html) - Why this exists, user journey
69
+ - [Architecture](./docs/architecture.html) - Technical diagrams
70
+ - [Roadmap](./docs/roadmap.html) - What's coming next
71
+
72
+ ## Features
73
+
74
+ ### Core Posting
75
+ - **Text posts** with hashtags and mentions
76
+ - **Link posts** with article previews (great for GitHub links!)
77
+ - **Image posts** (PNG, JPG, GIF)
78
+ - **Multi-image posts** (2-20 images)
79
+ - **Video posts** (MP4, MOV, up to 200MB)
80
+ - **Document posts** (PDF, PPT, DOC)
81
+ - **Poll posts** (2-4 options, 1-14 day duration)
82
+
83
+ ### Post Management
84
+ - Edit existing posts
85
+ - Delete posts
86
+ - List your recent posts with pagination
87
+
88
+ ### Scheduling
89
+ LinkedIn's API doesn't support native scheduling, so we built it:
90
+ - Schedule posts for any future time
91
+ - List scheduled posts by status
92
+ - Cancel pending posts
93
+
94
+ ### Social Interactions
95
+ - Add comments to posts
96
+ - React to posts (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
97
+
98
+ ## For Developers
99
+
100
+ Want to contribute or run locally?
101
+
102
+ ```bash
103
+ git clone https://github.com/ldraney/mcp-linkedin.git
104
+ cd mcp-linkedin
105
+ npm install
106
+ npm test # 118 tests
107
+ ```
108
+
109
+ See [CLAUDE.md](./CLAUDE.md) for development guidelines.
110
+
111
+ ## Privacy
112
+
113
+ - Your LinkedIn access token is stored locally on your machine
114
+ - Our OAuth backend only facilitates the initial authentication
115
+ - We never store or have access to your LinkedIn credentials
116
+ - All API calls go directly from your machine to LinkedIn
117
+
118
+ ## License
119
+
120
+ MIT - See [LICENSE](./LICENSE)
121
+
122
+ ## Author
123
+
124
+ Lucas Draney ([@ldraney](https://github.com/ldraney))
125
+
126
+ ---
127
+
128
+ **Questions?** Open an issue or reach out on LinkedIn!
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@ldraney/mcp-linkedin",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for LinkedIn automation - post, schedule, and engage from Claude Desktop",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "mcp-linkedin": "./src/index.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "src/**/*.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/ldraney/mcp-linkedin.git"
20
+ },
21
+ "homepage": "https://github.com/ldraney/mcp-linkedin",
22
+ "scripts": {
23
+ "start": "node src/index.js",
24
+ "auth": "node src/auth-server.js",
25
+ "scheduler": "node src/scheduler.js",
26
+ "test": "jest",
27
+ "test:watch": "jest --watch",
28
+ "test:coverage": "jest --coverage",
29
+ "test:manual": "node test_post.js"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "linkedin",
34
+ "api",
35
+ "automation"
36
+ ],
37
+ "author": "Lucas Draney",
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.0.0",
41
+ "better-sqlite3": "^11.6.0",
42
+ "dotenv": "^16.3.1",
43
+ "node-cron": "^3.0.3",
44
+ "uuid": "^11.0.3",
45
+ "zod": "^3.22.4"
46
+ },
47
+ "devDependencies": {
48
+ "jest": "^29.7.0"
49
+ }
50
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Spike: Localhost OAuth Callback Server
3
+ *
4
+ * Validates that MCP can spin up a temporary HTTP server to receive
5
+ * OAuth callback from Fly.io backend.
6
+ *
7
+ * Usage:
8
+ * node src/auth-server.js
9
+ *
10
+ * Then complete OAuth flow at:
11
+ * https://mcp-linkedin-oauth.fly.dev/auth/linkedin
12
+ */
13
+
14
+ const http = require('http');
15
+ const url = require('url');
16
+
17
+ const PREFERRED_PORT = 3000;
18
+ const MAX_PORT_ATTEMPTS = 10;
19
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
20
+
21
+ /**
22
+ * Start the OAuth callback server
23
+ * @returns {Promise<{code: string, port: number}>} Resolves with auth code when received
24
+ */
25
+ function startAuthServer() {
26
+ return new Promise((resolve, reject) => {
27
+ let timeoutId;
28
+ let server;
29
+
30
+ const handleCallback = (req, res) => {
31
+ const parsedUrl = url.parse(req.url, true);
32
+
33
+ // Only handle the callback path
34
+ if (parsedUrl.pathname !== '/oauth-callback') {
35
+ res.writeHead(404);
36
+ res.end('Not Found');
37
+ return;
38
+ }
39
+
40
+ const { code, error, error_description } = parsedUrl.query;
41
+
42
+ if (error) {
43
+ res.writeHead(400, { 'Content-Type': 'text/html' });
44
+ res.end(`
45
+ <html>
46
+ <head><title>Authentication Failed</title></head>
47
+ <body style="font-family: sans-serif; text-align: center; padding: 2rem;">
48
+ <h1 style="color: #dc2626;">Authentication Failed</h1>
49
+ <p>${error}: ${error_description || 'Unknown error'}</p>
50
+ <p>You can close this tab.</p>
51
+ </body>
52
+ </html>
53
+ `);
54
+ cleanup();
55
+ reject(new Error(`OAuth error: ${error} - ${error_description}`));
56
+ return;
57
+ }
58
+
59
+ if (!code) {
60
+ res.writeHead(400, { 'Content-Type': 'text/html' });
61
+ res.end(`
62
+ <html>
63
+ <head><title>Missing Code</title></head>
64
+ <body style="font-family: sans-serif; text-align: center; padding: 2rem;">
65
+ <h1 style="color: #dc2626;">Missing Authorization Code</h1>
66
+ <p>No code received from LinkedIn.</p>
67
+ <p>You can close this tab.</p>
68
+ </body>
69
+ </html>
70
+ `);
71
+ cleanup();
72
+ reject(new Error('No authorization code received'));
73
+ return;
74
+ }
75
+
76
+ // Success!
77
+ res.writeHead(200, { 'Content-Type': 'text/html' });
78
+ res.end(`
79
+ <html>
80
+ <head><title>Authentication Successful</title></head>
81
+ <body style="font-family: sans-serif; text-align: center; padding: 2rem;">
82
+ <h1 style="color: #10b981;">✓ Authentication Successful!</h1>
83
+ <p>You can close this tab and return to Claude.</p>
84
+ <script>setTimeout(() => window.close(), 3000);</script>
85
+ </body>
86
+ </html>
87
+ `);
88
+
89
+ console.log(`[auth-server] Received authorization code: ${code.substring(0, 20)}...`);
90
+
91
+ cleanup();
92
+ resolve({ code, port: server.address().port });
93
+ };
94
+
95
+ const cleanup = () => {
96
+ if (timeoutId) clearTimeout(timeoutId);
97
+ if (server) {
98
+ server.close(() => {
99
+ console.log('[auth-server] Server shut down');
100
+ });
101
+ }
102
+ };
103
+
104
+ server = http.createServer(handleCallback);
105
+
106
+ // Try to find an available port
107
+ const tryPort = (port, attempts = 0) => {
108
+ server.once('error', (err) => {
109
+ if (err.code === 'EADDRINUSE' && attempts < MAX_PORT_ATTEMPTS) {
110
+ console.log(`[auth-server] Port ${port} in use, trying ${port + 1}...`);
111
+ tryPort(port + 1, attempts + 1);
112
+ } else {
113
+ reject(err);
114
+ }
115
+ });
116
+
117
+ server.listen(port, '127.0.0.1', () => {
118
+ const actualPort = server.address().port;
119
+ console.log(`[auth-server] Listening on http://localhost:${actualPort}/oauth-callback`);
120
+ console.log(`[auth-server] Waiting for OAuth callback (timeout: ${CALLBACK_TIMEOUT_MS / 1000}s)...`);
121
+
122
+ // Set timeout
123
+ timeoutId = setTimeout(() => {
124
+ console.log('[auth-server] Timeout waiting for callback');
125
+ cleanup();
126
+ reject(new Error('Timeout waiting for OAuth callback'));
127
+ }, CALLBACK_TIMEOUT_MS);
128
+ });
129
+ };
130
+
131
+ tryPort(PREFERRED_PORT);
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Exchange authorization code for access token
137
+ * This would normally call linkedin-api.js exchangeCode()
138
+ */
139
+ async function exchangeCodeForToken(code) {
140
+ // For spike, just log that we would exchange
141
+ console.log(`[auth-server] Would exchange code for token: ${code.substring(0, 20)}...`);
142
+
143
+ // In real implementation:
144
+ // const { exchangeCode } = require('./linkedin-api');
145
+ // return exchangeCode(code);
146
+
147
+ return {
148
+ access_token: 'mock_token',
149
+ expires_in: 5184000,
150
+ refresh_token: 'mock_refresh'
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Store token to config directory
156
+ * This would normally save to ~/.config/mcp-linkedin/credentials.json
157
+ */
158
+ async function storeToken(tokenData) {
159
+ const fs = require('fs').promises;
160
+ const path = require('path');
161
+ const os = require('os');
162
+
163
+ const configDir = path.join(os.homedir(), '.config', 'mcp-linkedin');
164
+ const credentialsPath = path.join(configDir, 'credentials.json');
165
+
166
+ // Create config directory if needed
167
+ await fs.mkdir(configDir, { recursive: true });
168
+
169
+ // Store token with metadata
170
+ const credentials = {
171
+ ...tokenData,
172
+ obtained_at: new Date().toISOString(),
173
+ expires_at: new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString()
174
+ };
175
+
176
+ await fs.writeFile(credentialsPath, JSON.stringify(credentials, null, 2));
177
+ console.log(`[auth-server] Token stored at ${credentialsPath}`);
178
+
179
+ return credentialsPath;
180
+ }
181
+
182
+ // Main execution
183
+ async function main() {
184
+ console.log('='.repeat(60));
185
+ console.log('MCP LinkedIn OAuth Callback Server (Spike)');
186
+ console.log('='.repeat(60));
187
+ console.log('');
188
+ console.log('1. Open this URL in your browser:');
189
+ console.log(' https://mcp-linkedin-oauth.fly.dev/auth/linkedin');
190
+ console.log('');
191
+ console.log('2. Authorize with LinkedIn');
192
+ console.log('');
193
+ console.log('3. You will be redirected back here');
194
+ console.log('');
195
+ console.log('='.repeat(60));
196
+
197
+ try {
198
+ const { code, port } = await startAuthServer();
199
+ console.log(`\n[auth-server] Success! Received code on port ${port}`);
200
+
201
+ // Exchange code for token
202
+ const tokenData = await exchangeCodeForToken(code);
203
+ console.log('[auth-server] Token exchanged successfully');
204
+
205
+ // Store token
206
+ const credPath = await storeToken(tokenData);
207
+ console.log(`[auth-server] Credentials saved to ${credPath}`);
208
+
209
+ console.log('\n✓ OAuth flow complete!');
210
+ process.exit(0);
211
+ } catch (err) {
212
+ console.error(`\n✗ OAuth flow failed: ${err.message}`);
213
+ process.exit(1);
214
+ }
215
+ }
216
+
217
+ // Export for use as module
218
+ module.exports = { startAuthServer, exchangeCodeForToken, storeToken };
219
+
220
+ // Run if called directly
221
+ if (require.main === module) {
222
+ main();
223
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * @fileoverview SQLite database wrapper for scheduled LinkedIn posts
3
+ * @module database
4
+ */
5
+
6
+ const Database = require('better-sqlite3');
7
+ const path = require('path');
8
+ const { v4: uuidv4 } = require('uuid');
9
+
10
+ /**
11
+ * @typedef {import('./types').ScheduledPost} ScheduledPost
12
+ * @typedef {import('./types').ScheduledPostStatus} ScheduledPostStatus
13
+ */
14
+
15
+ const DEFAULT_DB_PATH = path.join(__dirname, '..', 'scheduled_posts.db');
16
+
17
+ /**
18
+ * Database wrapper for scheduled posts
19
+ */
20
+ class ScheduledPostsDB {
21
+ /**
22
+ * @param {string} [dbPath] - Path to the SQLite database file
23
+ */
24
+ constructor(dbPath = DEFAULT_DB_PATH) {
25
+ this.db = new Database(dbPath);
26
+ this.db.pragma('journal_mode = WAL');
27
+ this._initializeSchema();
28
+ }
29
+
30
+ /**
31
+ * Initialize the database schema
32
+ * @private
33
+ */
34
+ _initializeSchema() {
35
+ this.db.exec(`
36
+ CREATE TABLE IF NOT EXISTS scheduled_posts (
37
+ id TEXT PRIMARY KEY,
38
+ commentary TEXT NOT NULL,
39
+ url TEXT,
40
+ visibility TEXT DEFAULT 'PUBLIC',
41
+ scheduled_time TEXT NOT NULL,
42
+ status TEXT DEFAULT 'pending',
43
+ created_at TEXT NOT NULL,
44
+ published_at TEXT,
45
+ post_urn TEXT,
46
+ error_message TEXT,
47
+ retry_count INTEGER DEFAULT 0
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_status ON scheduled_posts(status);
51
+ CREATE INDEX IF NOT EXISTS idx_scheduled_time ON scheduled_posts(scheduled_time);
52
+ `);
53
+ }
54
+
55
+ /**
56
+ * Add a new scheduled post
57
+ * @param {Object} data - Post data
58
+ * @param {string} data.commentary - Post text
59
+ * @param {string} data.scheduledTime - ISO 8601 datetime string
60
+ * @param {string} [data.url] - Optional URL for link posts
61
+ * @param {string} [data.visibility='PUBLIC'] - Post visibility
62
+ * @returns {ScheduledPost} The created scheduled post
63
+ */
64
+ addScheduledPost({ commentary, scheduledTime, url = null, visibility = 'PUBLIC' }) {
65
+ const id = uuidv4();
66
+ const createdAt = new Date().toISOString();
67
+
68
+ const stmt = this.db.prepare(`
69
+ INSERT INTO scheduled_posts (id, commentary, url, visibility, scheduled_time, status, created_at)
70
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)
71
+ `);
72
+
73
+ stmt.run(id, commentary, url, visibility, scheduledTime, createdAt);
74
+
75
+ return this.getScheduledPost(id);
76
+ }
77
+
78
+ /**
79
+ * Get a single scheduled post by ID
80
+ * @param {string} id - Post ID
81
+ * @returns {ScheduledPost|null} The scheduled post or null if not found
82
+ */
83
+ getScheduledPost(id) {
84
+ const stmt = this.db.prepare('SELECT * FROM scheduled_posts WHERE id = ?');
85
+ const row = stmt.get(id);
86
+ return row ? this._rowToPost(row) : null;
87
+ }
88
+
89
+ /**
90
+ * Get all scheduled posts, optionally filtered by status
91
+ * @param {ScheduledPostStatus} [status] - Filter by status
92
+ * @param {number} [limit=50] - Maximum number of posts to return
93
+ * @returns {ScheduledPost[]} Array of scheduled posts
94
+ */
95
+ getScheduledPosts(status = null, limit = 50) {
96
+ let stmt;
97
+ if (status) {
98
+ stmt = this.db.prepare(`
99
+ SELECT * FROM scheduled_posts
100
+ WHERE status = ?
101
+ ORDER BY scheduled_time ASC
102
+ LIMIT ?
103
+ `);
104
+ return stmt.all(status, limit).map(row => this._rowToPost(row));
105
+ } else {
106
+ stmt = this.db.prepare(`
107
+ SELECT * FROM scheduled_posts
108
+ ORDER BY scheduled_time ASC
109
+ LIMIT ?
110
+ `);
111
+ return stmt.all(limit).map(row => this._rowToPost(row));
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Get posts that are due to be published (scheduled time has passed and status is pending)
117
+ * @returns {ScheduledPost[]} Array of posts ready to publish
118
+ */
119
+ getDuePosts() {
120
+ const now = new Date().toISOString();
121
+ const stmt = this.db.prepare(`
122
+ SELECT * FROM scheduled_posts
123
+ WHERE status = 'pending' AND scheduled_time <= ?
124
+ ORDER BY scheduled_time ASC
125
+ `);
126
+ return stmt.all(now).map(row => this._rowToPost(row));
127
+ }
128
+
129
+ /**
130
+ * Update post status after successful publish
131
+ * @param {string} id - Post ID
132
+ * @param {string} postUrn - The URN of the published post
133
+ * @returns {ScheduledPost|null} Updated post or null if not found
134
+ */
135
+ markAsPublished(id, postUrn) {
136
+ const publishedAt = new Date().toISOString();
137
+ const stmt = this.db.prepare(`
138
+ UPDATE scheduled_posts
139
+ SET status = 'published', published_at = ?, post_urn = ?
140
+ WHERE id = ?
141
+ `);
142
+ stmt.run(publishedAt, postUrn, id);
143
+ return this.getScheduledPost(id);
144
+ }
145
+
146
+ /**
147
+ * Mark post as failed with error message
148
+ * @param {string} id - Post ID
149
+ * @param {string} errorMessage - Error description
150
+ * @returns {ScheduledPost|null} Updated post or null if not found
151
+ */
152
+ markAsFailed(id, errorMessage) {
153
+ const stmt = this.db.prepare(`
154
+ UPDATE scheduled_posts
155
+ SET status = 'failed', error_message = ?, retry_count = retry_count + 1
156
+ WHERE id = ?
157
+ `);
158
+ stmt.run(errorMessage, id);
159
+ return this.getScheduledPost(id);
160
+ }
161
+
162
+ /**
163
+ * Cancel a scheduled post
164
+ * @param {string} id - Post ID
165
+ * @returns {ScheduledPost|null} Updated post or null if not found
166
+ */
167
+ cancelPost(id) {
168
+ const stmt = this.db.prepare(`
169
+ UPDATE scheduled_posts
170
+ SET status = 'cancelled'
171
+ WHERE id = ? AND status = 'pending'
172
+ `);
173
+ const result = stmt.run(id);
174
+ if (result.changes === 0) {
175
+ return null; // Post not found or not in pending status
176
+ }
177
+ return this.getScheduledPost(id);
178
+ }
179
+
180
+ /**
181
+ * Delete a scheduled post
182
+ * @param {string} id - Post ID
183
+ * @returns {boolean} True if deleted, false if not found
184
+ */
185
+ deleteScheduledPost(id) {
186
+ const stmt = this.db.prepare('DELETE FROM scheduled_posts WHERE id = ?');
187
+ const result = stmt.run(id);
188
+ return result.changes > 0;
189
+ }
190
+
191
+ /**
192
+ * Reset a failed post to pending for retry
193
+ * @param {string} id - Post ID
194
+ * @returns {ScheduledPost|null} Updated post or null if not found
195
+ */
196
+ resetForRetry(id) {
197
+ const stmt = this.db.prepare(`
198
+ UPDATE scheduled_posts
199
+ SET status = 'pending', error_message = NULL
200
+ WHERE id = ? AND status = 'failed'
201
+ `);
202
+ const result = stmt.run(id);
203
+ if (result.changes === 0) {
204
+ return null;
205
+ }
206
+ return this.getScheduledPost(id);
207
+ }
208
+
209
+ /**
210
+ * Update scheduled time for a pending post
211
+ * @param {string} id - Post ID
212
+ * @param {string} newScheduledTime - New ISO 8601 datetime string
213
+ * @returns {ScheduledPost|null} Updated post or null if not found/not pending
214
+ */
215
+ reschedulePost(id, newScheduledTime) {
216
+ const stmt = this.db.prepare(`
217
+ UPDATE scheduled_posts
218
+ SET scheduled_time = ?
219
+ WHERE id = ? AND status = 'pending'
220
+ `);
221
+ const result = stmt.run(newScheduledTime, id);
222
+ if (result.changes === 0) {
223
+ return null;
224
+ }
225
+ return this.getScheduledPost(id);
226
+ }
227
+
228
+ /**
229
+ * Convert database row to ScheduledPost object
230
+ * @private
231
+ * @param {Object} row - Database row
232
+ * @returns {ScheduledPost}
233
+ */
234
+ _rowToPost(row) {
235
+ return {
236
+ id: row.id,
237
+ commentary: row.commentary,
238
+ url: row.url,
239
+ visibility: row.visibility,
240
+ scheduledTime: row.scheduled_time,
241
+ status: row.status,
242
+ createdAt: row.created_at,
243
+ publishedAt: row.published_at,
244
+ postUrn: row.post_urn,
245
+ errorMessage: row.error_message,
246
+ retryCount: row.retry_count
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Close the database connection
252
+ */
253
+ close() {
254
+ this.db.close();
255
+ }
256
+ }
257
+
258
+ // Singleton instance for the application
259
+ let dbInstance = null;
260
+
261
+ /**
262
+ * Get the database instance (creates one if needed)
263
+ * @param {string} [dbPath] - Optional path for testing
264
+ * @returns {ScheduledPostsDB}
265
+ */
266
+ function getDatabase(dbPath) {
267
+ if (!dbInstance || dbPath) {
268
+ dbInstance = new ScheduledPostsDB(dbPath);
269
+ }
270
+ return dbInstance;
271
+ }
272
+
273
+ /**
274
+ * Reset the database instance (for testing)
275
+ */
276
+ function resetDatabase() {
277
+ if (dbInstance) {
278
+ dbInstance.close();
279
+ dbInstance = null;
280
+ }
281
+ }
282
+
283
+ module.exports = {
284
+ ScheduledPostsDB,
285
+ getDatabase,
286
+ resetDatabase,
287
+ DEFAULT_DB_PATH
288
+ };