suparank 1.2.5 → 1.2.6

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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Suparank MCP - Configuration
3
+ *
4
+ * Centralized configuration and constants
5
+ */
6
+
7
+ // Parse command line arguments
8
+ export const projectSlug = process.argv[2]
9
+ export const apiKey = process.argv[3]
10
+ export const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
11
+
12
+ // External API endpoints - configurable via environment variables
13
+ export const API_ENDPOINTS = {
14
+ fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
15
+ gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
16
+ wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
17
+ wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
18
+ }
19
+
20
+ // Session expiration (24 hours)
21
+ export const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000
22
+
23
+ // Tools that are visible in the MCP tool list
24
+ export const VISIBLE_TOOLS = [
25
+ // Essential (5) - Main workflow
26
+ 'create_content', 'keyword_research', 'generate_image', 'publish_content', 'get_session',
27
+ // Session Management (5) - Content lifecycle
28
+ 'save_content', 'list_content', 'load_content', 'remove_article', 'clear_session'
29
+ ]
30
+
31
+ // Default stats object
32
+ export const DEFAULT_STATS = {
33
+ tool_calls: 0,
34
+ images_generated: 0,
35
+ articles_created: 0,
36
+ words_written: 0
37
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Suparank MCP - Main Entry Point
3
+ *
4
+ * Modular MCP server for AI-powered SEO content creation
5
+ *
6
+ * This file re-exports from modular components and provides the main entry point.
7
+ * For now, the main server logic is still in ../mcp-client.js but will be
8
+ * migrated incrementally.
9
+ */
10
+
11
+ // Re-export config
12
+ export * from './config.js'
13
+
14
+ // Re-export utils
15
+ export * from './utils/index.js'
16
+
17
+ // Re-export services
18
+ export * from './services/index.js'
19
+
20
+ // Main entry - for now, delegate to the original mcp-client.js
21
+ // This will be replaced with modular server setup in future versions
22
+ import '../mcp-client.js'
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Suparank MCP - API Service
3
+ *
4
+ * HTTP request utilities with retry and timeout handling
5
+ */
6
+
7
+ import { log } from '../utils/logging.js'
8
+
9
+ /**
10
+ * Fetch with timeout - prevents hanging requests
11
+ * @param {string} url - URL to fetch
12
+ * @param {object} options - Fetch options
13
+ * @param {number} timeoutMs - Timeout in milliseconds
14
+ * @returns {Promise<Response>}
15
+ */
16
+ export async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
17
+ const controller = new AbortController()
18
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
19
+
20
+ try {
21
+ const response = await fetch(url, {
22
+ ...options,
23
+ signal: controller.signal
24
+ })
25
+ return response
26
+ } finally {
27
+ clearTimeout(timeout)
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Fetch with retry - handles transient failures
33
+ * @param {string} url - URL to fetch
34
+ * @param {object} options - Fetch options
35
+ * @param {number} maxRetries - Maximum retry attempts
36
+ * @param {number} timeoutMs - Timeout per request in milliseconds
37
+ * @returns {Promise<Response>}
38
+ */
39
+ export async function fetchWithRetry(url, options = {}, maxRetries = 3, timeoutMs = 30000) {
40
+ let lastError
41
+
42
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
43
+ try {
44
+ const response = await fetchWithTimeout(url, options, timeoutMs)
45
+
46
+ // Retry on 5xx errors or rate limiting
47
+ if (response.status >= 500 || response.status === 429) {
48
+ const retryAfter = response.headers.get('retry-after')
49
+ const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000
50
+
51
+ if (attempt < maxRetries) {
52
+ log(`Request failed (${response.status}), retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
53
+ await new Promise(resolve => setTimeout(resolve, delay))
54
+ continue
55
+ }
56
+ }
57
+
58
+ return response
59
+ } catch (error) {
60
+ lastError = error
61
+
62
+ // Don't retry on abort (timeout)
63
+ if (error.name === 'AbortError') {
64
+ throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`)
65
+ }
66
+
67
+ // Retry on network errors
68
+ if (attempt < maxRetries) {
69
+ const delay = Math.pow(2, attempt) * 1000
70
+ log(`Network error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries}): ${error.message}`)
71
+ await new Promise(resolve => setTimeout(resolve, delay))
72
+ continue
73
+ }
74
+ }
75
+ }
76
+
77
+ throw lastError || new Error(`Request failed after ${maxRetries} attempts: ${url}`)
78
+ }
79
+
80
+ /**
81
+ * Make an authenticated request to the Suparank API
82
+ * @param {string} apiUrl - Base API URL
83
+ * @param {string} apiKey - API key
84
+ * @param {string} endpoint - API endpoint
85
+ * @param {object} options - Additional fetch options
86
+ * @returns {Promise<Response>}
87
+ */
88
+ export async function apiRequest(apiUrl, apiKey, endpoint, options = {}) {
89
+ const url = `${apiUrl}${endpoint}`
90
+
91
+ const headers = {
92
+ 'Authorization': `Bearer ${apiKey}`,
93
+ 'Content-Type': 'application/json',
94
+ ...options.headers
95
+ }
96
+
97
+ return fetchWithRetry(url, {
98
+ ...options,
99
+ headers
100
+ })
101
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Suparank MCP - Credentials Service
3
+ *
4
+ * Local credentials management for CMS and API integrations
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import { getCredentialsFilePath } from '../utils/paths.js'
9
+ import { log } from '../utils/logging.js'
10
+
11
+ // Cached credentials
12
+ let localCredentials = null
13
+
14
+ /**
15
+ * Load local credentials from ~/.suparank/credentials.json
16
+ * @returns {object|null} Credentials object or null
17
+ */
18
+ export function loadCredentials() {
19
+ if (localCredentials !== null) {
20
+ return localCredentials
21
+ }
22
+
23
+ const credentialsPath = getCredentialsFilePath()
24
+
25
+ try {
26
+ if (fs.existsSync(credentialsPath)) {
27
+ const content = fs.readFileSync(credentialsPath, 'utf-8')
28
+ localCredentials = JSON.parse(content)
29
+ log(`Loaded credentials from ${credentialsPath}`)
30
+
31
+ // Log which integrations are configured (without exposing keys)
32
+ const configured = []
33
+ if (localCredentials.wordpress?.secret_key || localCredentials.wordpress?.app_password) {
34
+ configured.push('WordPress')
35
+ }
36
+ if (localCredentials.ghost?.admin_api_key) {
37
+ configured.push('Ghost')
38
+ }
39
+ if (localCredentials.image_provider && localCredentials[localCredentials.image_provider]?.api_key) {
40
+ configured.push(`Images (${localCredentials.image_provider})`)
41
+ }
42
+ if (localCredentials.webhooks && Object.values(localCredentials.webhooks).some(Boolean)) {
43
+ configured.push('Webhooks')
44
+ }
45
+ if (localCredentials.external_mcps?.length) {
46
+ configured.push(`External MCPs (${localCredentials.external_mcps.length})`)
47
+ }
48
+
49
+ if (configured.length > 0) {
50
+ log(`Configured integrations: ${configured.join(', ')}`)
51
+ }
52
+
53
+ return localCredentials
54
+ }
55
+ } catch (error) {
56
+ log(`Warning: Could not load credentials: ${error.message}`)
57
+ }
58
+
59
+ localCredentials = {}
60
+ return localCredentials
61
+ }
62
+
63
+ /**
64
+ * Get the current credentials object
65
+ * @returns {object} Credentials object
66
+ */
67
+ export function getCredentials() {
68
+ if (localCredentials === null) {
69
+ loadCredentials()
70
+ }
71
+ return localCredentials || {}
72
+ }
73
+
74
+ /**
75
+ * Check if a specific credential type is available
76
+ * @param {string} type - Credential type (wordpress, ghost, fal, gemini, wiro, image, webhooks)
77
+ * @returns {boolean} Whether the credential is available
78
+ */
79
+ export function hasCredential(type) {
80
+ const creds = getCredentials()
81
+
82
+ switch (type) {
83
+ case 'wordpress':
84
+ return !!(creds.wordpress?.secret_key || creds.wordpress?.app_password)
85
+ case 'ghost':
86
+ return !!creds.ghost?.admin_api_key
87
+ case 'fal':
88
+ return !!creds.fal?.api_key
89
+ case 'gemini':
90
+ return !!creds.gemini?.api_key
91
+ case 'wiro':
92
+ return !!creds.wiro?.api_key
93
+ case 'image':
94
+ const provider = creds.image_provider
95
+ return provider && hasCredential(provider)
96
+ case 'webhooks':
97
+ return !!(creds.webhooks && Object.values(creds.webhooks).some(Boolean))
98
+ default:
99
+ return false
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Get the configured image provider
105
+ * @returns {string|null} Image provider name or null
106
+ */
107
+ export function getImageProvider() {
108
+ const creds = getCredentials()
109
+ return creds.image_provider || null
110
+ }
111
+
112
+ /**
113
+ * Get configuration for a specific provider
114
+ * @param {string} provider - Provider name
115
+ * @returns {object|null} Provider config or null
116
+ */
117
+ export function getProviderConfig(provider) {
118
+ const creds = getCredentials()
119
+ return creds[provider] || null
120
+ }
121
+
122
+ /**
123
+ * Clear cached credentials (for testing)
124
+ */
125
+ export function clearCredentialsCache() {
126
+ localCredentials = null
127
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Suparank MCP - Services Index
3
+ *
4
+ * Re-exports all service modules
5
+ */
6
+
7
+ export * from './api.js'
8
+ export * from './credentials.js'
9
+ export * from './session-state.js'
10
+ export * from './stats.js'
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Suparank MCP - Session State Service
3
+ *
4
+ * Session state management with persistence and mutex protection
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import {
9
+ getSessionFilePath,
10
+ ensureSuparankDir,
11
+ atomicWriteSync
12
+ } from '../utils/paths.js'
13
+ import { log, progress } from '../utils/logging.js'
14
+ import { SESSION_EXPIRY_MS } from '../config.js'
15
+
16
+ // Session state object
17
+ export const sessionState = {
18
+ currentWorkflow: null,
19
+ stepResults: {},
20
+ articles: [],
21
+ article: null,
22
+ title: null,
23
+ imageUrl: null,
24
+ inlineImages: [],
25
+ keywords: null,
26
+ metadata: null,
27
+ metaTitle: null,
28
+ metaDescription: null,
29
+ contentFolder: null
30
+ }
31
+
32
+ // Session mutex for concurrent access protection
33
+ let sessionLock = false
34
+ const sessionLockQueue = []
35
+
36
+ /**
37
+ * Acquire session lock for safe concurrent access
38
+ * @returns {Promise<void>}
39
+ */
40
+ export async function acquireSessionLock() {
41
+ if (!sessionLock) {
42
+ sessionLock = true
43
+ return
44
+ }
45
+ return new Promise(resolve => {
46
+ sessionLockQueue.push(resolve)
47
+ })
48
+ }
49
+
50
+ /**
51
+ * Release session lock
52
+ */
53
+ export function releaseSessionLock() {
54
+ if (sessionLockQueue.length > 0) {
55
+ const next = sessionLockQueue.shift()
56
+ next()
57
+ } else {
58
+ sessionLock = false
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Generate a unique article ID
64
+ * @returns {string} Unique ID
65
+ */
66
+ export function generateArticleId() {
67
+ return `article-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`
68
+ }
69
+
70
+ /**
71
+ * Check if session has expired
72
+ * @param {string} savedAt - ISO timestamp when session was saved
73
+ * @returns {boolean} Whether session has expired
74
+ */
75
+ export function isSessionExpired(savedAt) {
76
+ if (!savedAt) return true
77
+ const savedTime = new Date(savedAt).getTime()
78
+ const now = Date.now()
79
+ return (now - savedTime) > SESSION_EXPIRY_MS
80
+ }
81
+
82
+ /**
83
+ * Save session state to file
84
+ * Uses atomic write to prevent corruption
85
+ */
86
+ export function saveSession() {
87
+ try {
88
+ ensureSuparankDir()
89
+ const sessionFile = getSessionFilePath()
90
+
91
+ const toSave = {
92
+ currentWorkflow: sessionState.currentWorkflow,
93
+ stepResults: sessionState.stepResults,
94
+ articles: sessionState.articles,
95
+ article: sessionState.article,
96
+ title: sessionState.title,
97
+ imageUrl: sessionState.imageUrl,
98
+ inlineImages: sessionState.inlineImages,
99
+ keywords: sessionState.keywords,
100
+ metadata: sessionState.metadata,
101
+ metaTitle: sessionState.metaTitle,
102
+ metaDescription: sessionState.metaDescription,
103
+ contentFolder: sessionState.contentFolder,
104
+ savedAt: new Date().toISOString()
105
+ }
106
+
107
+ atomicWriteSync(sessionFile, JSON.stringify(toSave, null, 2))
108
+ progress('Session', `Saved to ${sessionFile} (${sessionState.articles.length} articles)`)
109
+ } catch (error) {
110
+ log(`Warning: Failed to save session: ${error.message}`)
111
+ progress('Session', `FAILED to save: ${error.message}`)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Safe session save with mutex
117
+ * @returns {Promise<void>}
118
+ */
119
+ export async function saveSessionSafe() {
120
+ await acquireSessionLock()
121
+ try {
122
+ saveSession()
123
+ } finally {
124
+ releaseSessionLock()
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Load session state from file
130
+ * @returns {boolean} Whether session was restored
131
+ */
132
+ export function restoreSession() {
133
+ const sessionFile = getSessionFilePath()
134
+
135
+ try {
136
+ if (fs.existsSync(sessionFile)) {
137
+ const content = fs.readFileSync(sessionFile, 'utf-8')
138
+ const saved = JSON.parse(content)
139
+
140
+ // Check if session is expired (24 hour max)
141
+ if (isSessionExpired(saved.savedAt)) {
142
+ log('Session expired, starting fresh')
143
+ return false
144
+ }
145
+
146
+ // Restore state
147
+ sessionState.currentWorkflow = saved.currentWorkflow || null
148
+ sessionState.stepResults = saved.stepResults || {}
149
+ sessionState.articles = saved.articles || []
150
+ sessionState.article = saved.article || null
151
+ sessionState.title = saved.title || null
152
+ sessionState.imageUrl = saved.imageUrl || null
153
+ sessionState.inlineImages = saved.inlineImages || []
154
+ sessionState.keywords = saved.keywords || null
155
+ sessionState.metadata = saved.metadata || null
156
+ sessionState.metaTitle = saved.metaTitle || null
157
+ sessionState.metaDescription = saved.metaDescription || null
158
+ sessionState.contentFolder = saved.contentFolder || null
159
+
160
+ log(`Session restored: ${sessionState.articles.length} articles, workflow: ${sessionState.currentWorkflow?.workflow_id || 'none'}`)
161
+ return true
162
+ }
163
+ } catch (error) {
164
+ log(`Warning: Could not restore session: ${error.message}`)
165
+ }
166
+
167
+ return false
168
+ }
169
+
170
+ /**
171
+ * Reset session state to initial values
172
+ */
173
+ export function resetSession() {
174
+ sessionState.currentWorkflow = null
175
+ sessionState.stepResults = {}
176
+ sessionState.articles = []
177
+ sessionState.article = null
178
+ sessionState.title = null
179
+ sessionState.imageUrl = null
180
+ sessionState.inlineImages = []
181
+ sessionState.keywords = null
182
+ sessionState.metadata = null
183
+ sessionState.metaTitle = null
184
+ sessionState.metaDescription = null
185
+ sessionState.contentFolder = null
186
+ }
187
+
188
+ /**
189
+ * Clear session file from disk
190
+ */
191
+ export function clearSessionFile() {
192
+ const sessionFile = getSessionFilePath()
193
+ try {
194
+ if (fs.existsSync(sessionFile)) {
195
+ fs.unlinkSync(sessionFile)
196
+ log('Session file cleared')
197
+ }
198
+ } catch (error) {
199
+ log(`Warning: Could not clear session file: ${error.message}`)
200
+ }
201
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Suparank MCP - Stats Service
3
+ *
4
+ * Usage statistics tracking
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import { getStatsFilePath, ensureSuparankDir } from '../utils/paths.js'
9
+ import { log } from '../utils/logging.js'
10
+ import { DEFAULT_STATS } from '../config.js'
11
+
12
+ /**
13
+ * Load usage stats from file
14
+ * @returns {object} Stats object
15
+ */
16
+ export function loadStats() {
17
+ try {
18
+ const file = getStatsFilePath()
19
+ if (fs.existsSync(file)) {
20
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
21
+ }
22
+ } catch (e) {
23
+ log(`Warning: Could not load stats: ${e.message}`)
24
+ }
25
+ return { ...DEFAULT_STATS }
26
+ }
27
+
28
+ /**
29
+ * Save usage stats to file
30
+ * @param {object} stats - Stats object to save
31
+ */
32
+ export function saveStats(stats) {
33
+ try {
34
+ ensureSuparankDir()
35
+ fs.writeFileSync(getStatsFilePath(), JSON.stringify(stats, null, 2))
36
+ } catch (e) {
37
+ log(`Error saving stats: ${e.message}`)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Increment a stat counter
43
+ * @param {string} key - Stat key to increment
44
+ * @param {number} amount - Amount to add (default: 1)
45
+ */
46
+ export function incrementStat(key, amount = 1) {
47
+ const stats = loadStats()
48
+ stats[key] = (stats[key] || 0) + amount
49
+ saveStats(stats)
50
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Suparank MCP - Utils Index
3
+ *
4
+ * Re-exports all utility modules
5
+ */
6
+
7
+ export * from './logging.js'
8
+ export * from './paths.js'
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Suparank MCP - Logging Utilities
3
+ *
4
+ * Standardized logging to stderr (stdout is for MCP protocol)
5
+ */
6
+
7
+ /**
8
+ * Log message to stderr with [suparank] prefix
9
+ * @param {...any} args - Arguments to log
10
+ */
11
+ export function log(...args) {
12
+ console.error('[suparank]', ...args)
13
+ }
14
+
15
+ /**
16
+ * Structured progress logging for user visibility
17
+ * @param {string} step - Current step name
18
+ * @param {string} message - Progress message
19
+ */
20
+ export function progress(step, message) {
21
+ console.error(`[suparank] ${step}: ${message}`)
22
+ }
23
+
24
+ /**
25
+ * Log warning message
26
+ * @param {...any} args - Arguments to log
27
+ */
28
+ export function warn(...args) {
29
+ console.error('[suparank] WARNING:', ...args)
30
+ }
31
+
32
+ /**
33
+ * Log error message
34
+ * @param {...any} args - Arguments to log
35
+ */
36
+ export function error(...args) {
37
+ console.error('[suparank] ERROR:', ...args)
38
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Suparank MCP - Path Utilities
3
+ *
4
+ * Safe path handling and directory management
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import * as path from 'path'
9
+ import * as os from 'os'
10
+
11
+ /**
12
+ * Get the Suparank configuration directory
13
+ * @returns {string} Path to ~/.suparank
14
+ */
15
+ export function getSuparankDir() {
16
+ return path.join(os.homedir(), '.suparank')
17
+ }
18
+
19
+ /**
20
+ * Get the content storage directory
21
+ * @returns {string} Path to ~/.suparank/content
22
+ */
23
+ export function getContentDir() {
24
+ return path.join(getSuparankDir(), 'content')
25
+ }
26
+
27
+ /**
28
+ * Get session file path
29
+ * @returns {string} Path to ~/.suparank/session.json
30
+ */
31
+ export function getSessionFilePath() {
32
+ return path.join(getSuparankDir(), 'session.json')
33
+ }
34
+
35
+ /**
36
+ * Get credentials file path
37
+ * @returns {string} Path to ~/.suparank/credentials.json
38
+ */
39
+ export function getCredentialsFilePath() {
40
+ return path.join(getSuparankDir(), 'credentials.json')
41
+ }
42
+
43
+ /**
44
+ * Get stats file path
45
+ * @returns {string} Path to ~/.suparank/stats.json
46
+ */
47
+ export function getStatsFilePath() {
48
+ return path.join(getSuparankDir(), 'stats.json')
49
+ }
50
+
51
+ /**
52
+ * Ensure the Suparank directory exists
53
+ */
54
+ export function ensureSuparankDir() {
55
+ const dir = getSuparankDir()
56
+ if (!fs.existsSync(dir)) {
57
+ fs.mkdirSync(dir, { recursive: true })
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Ensure the content directory exists
63
+ */
64
+ export function ensureContentDir() {
65
+ const dir = getContentDir()
66
+ if (!fs.existsSync(dir)) {
67
+ fs.mkdirSync(dir, { recursive: true })
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Sanitize and validate a path to prevent traversal attacks
73
+ * @param {string} userPath - User-provided path segment
74
+ * @param {string} allowedBase - Base directory that paths must stay within
75
+ * @returns {string} Resolved safe path
76
+ * @throws {Error} If path would escape the allowed base
77
+ */
78
+ export function sanitizePath(userPath, allowedBase) {
79
+ // Remove any null bytes (common attack vector)
80
+ const cleanPath = userPath.replace(/\0/g, '')
81
+
82
+ // Resolve to absolute path
83
+ const resolved = path.resolve(allowedBase, cleanPath)
84
+
85
+ // Ensure the resolved path starts with the allowed base
86
+ const normalizedBase = path.normalize(allowedBase + path.sep)
87
+ const normalizedResolved = path.normalize(resolved + path.sep)
88
+
89
+ if (!normalizedResolved.startsWith(normalizedBase)) {
90
+ throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
91
+ }
92
+
93
+ return resolved
94
+ }
95
+
96
+ /**
97
+ * Get content folder path safely
98
+ * @param {string} folderName - Folder name (article ID or title slug)
99
+ * @returns {string} Safe path to content folder
100
+ */
101
+ export function getContentFolderSafe(folderName) {
102
+ const contentDir = getContentDir()
103
+ return sanitizePath(folderName, contentDir)
104
+ }
105
+
106
+ /**
107
+ * Generate a slug from title for folder naming
108
+ * @param {string} text - Text to slugify
109
+ * @returns {string} URL-safe slug
110
+ */
111
+ export function slugify(text) {
112
+ return text
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9]+/g, '-')
115
+ .replace(/^-|-$/g, '')
116
+ .substring(0, 50)
117
+ }
118
+
119
+ /**
120
+ * Atomic file write - prevents corruption on concurrent writes
121
+ * @param {string} filePath - Target file path
122
+ * @param {string} data - Data to write
123
+ */
124
+ export function atomicWriteSync(filePath, data) {
125
+ const tmpFile = filePath + '.tmp.' + process.pid
126
+ try {
127
+ fs.writeFileSync(tmpFile, data)
128
+ fs.renameSync(tmpFile, filePath) // Atomic on POSIX
129
+ } catch (error) {
130
+ // Clean up temp file if rename failed
131
+ try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
132
+ throw error
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparank",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "description": "AI-powered SEO content creation MCP - generate and publish optimized blog posts with Claude, ChatGPT, or Cursor",
5
5
  "main": "mcp-client.js",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "mcp-client.js",
13
+ "mcp-client/",
13
14
  "credentials.example.json",
14
15
  "README.md",
15
16
  "LICENSE"