seo-intel 1.1.7 → 1.1.9

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/lib/license.js CHANGED
@@ -1,22 +1,25 @@
1
1
  /**
2
2
  * SEO Intel — License System
3
3
  *
4
- * Validation priority:
5
- * 1. FROGGO_TOKEN → validate against Froggo API
6
- * 2. SEO_INTEL_LICENSEvalidate against Lemon Squeezy API
7
- * 3. No key → Free tier
4
+ * Validation flow:
5
+ * 1. SEO_INTEL_LICENSEactivate/validate against Lemon Squeezy License API
6
+ * 2. No key Free tier
8
7
  *
9
8
  * Local cache: ~/.seo-intel/license-cache.json
10
9
  * - LS keys: cache 24h, stale up to 7 days if API unreachable
11
- * - Froggo tokens: cache 24h, stale up to 24h if API unreachable
12
10
  * - Beyond stale limit → degrade to free tier + warn
13
11
  *
12
+ * Activation flow (Lemon Squeezy):
13
+ * - First run: POST /v1/licenses/activate → stores instance_id locally
14
+ * - Subsequent runs: POST /v1/licenses/validate with instance_id
15
+ * - Machine-specific: instance_name = "seo-intel-<machineId>"
16
+ *
14
17
  * Free tier: crawl + raw HTML export only. No AI extraction or analysis.
15
- * Solo (€19.99/mo or €199/yr via LS, $9.99/mo via Froggo): Full AI extraction + analysis, all commands.
18
+ * Solo (€19.99/mo or €199.99/yr): Full AI extraction + analysis, all commands.
16
19
  * Agency: Later phase — not sold yet.
17
20
  */
18
21
 
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
22
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
20
23
  import { join, dirname } from 'path';
21
24
  import { fileURLToPath } from 'url';
22
25
  import { hostname, userInfo, platform } from 'os';
@@ -32,8 +35,6 @@ const CACHE_PATH = join(CACHE_DIR, 'license-cache.json');
32
35
  // Stale limits (ms)
33
36
  const LS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
34
37
  const LS_STALE_LIMIT = 7 * 24 * 60 * 60 * 1000; // 7 days stale max
35
- const FROGGO_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
36
- const FROGGO_STALE_LIMIT = 24 * 60 * 60 * 1000; // 24h stale max
37
38
 
38
39
  // ── Tiers ──────────────────────────────────────────────────────────────────
39
40
 
@@ -91,9 +92,18 @@ function writeCache(data) {
91
92
  try {
92
93
  mkdirSync(CACHE_DIR, { recursive: true });
93
94
  writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2), 'utf8');
95
+ try { chmodSync(CACHE_PATH, 0o600); } catch {}
94
96
  } catch { /* best-effort */ }
95
97
  }
96
98
 
99
+ function getTierFromVariantName(variantName) {
100
+ const normalized = (variantName || '').toLowerCase();
101
+ if (!normalized.includes('agency') && !normalized.includes('solo') && !normalized.includes('pro')) {
102
+ console.warn(`[license] Unknown variant name: "${normalized}" — defaulting to solo`);
103
+ }
104
+ return normalized.includes('agency') ? 'agency' : 'solo';
105
+ }
106
+
97
107
  /**
98
108
  * Check if cached validation is still usable.
99
109
  * Returns { valid, tier, stale } or null if cache is expired/missing.
@@ -103,123 +113,176 @@ function checkCache(key) {
103
113
  if (!cache || cache.key !== key) return null;
104
114
 
105
115
  const age = Date.now() - (cache.validatedAt || 0);
106
- const ttl = cache.source === 'froggo' ? FROGGO_CACHE_TTL : LS_CACHE_TTL;
107
- const staleLimit = cache.source === 'froggo' ? FROGGO_STALE_LIMIT : LS_STALE_LIMIT;
108
116
 
109
- if (age < ttl) {
110
- return { valid: true, tier: cache.tier, stale: false, source: cache.source };
117
+ if (age < LS_CACHE_TTL) {
118
+ return { valid: true, tier: cache.tier, stale: false, source: 'lemon-squeezy', instanceId: cache.instanceId };
111
119
  }
112
- if (age < staleLimit) {
113
- return { valid: true, tier: cache.tier, stale: true, source: cache.source };
120
+ if (age < LS_STALE_LIMIT) {
121
+ return { valid: true, tier: cache.tier, stale: true, source: 'lemon-squeezy', instanceId: cache.instanceId };
114
122
  }
115
123
  return null; // Expired beyond stale limit
116
124
  }
117
125
 
118
- // ── Lemon Squeezy Validation ───────────────────────────────────────────────
126
+ // ── Lemon Squeezy License API ────────────────────────────────────────────
119
127
 
120
128
  /**
121
- * Validate a license key against Lemon Squeezy API.
122
- * Returns { valid, tier, error? } — never throws.
129
+ * Activate a license key against Lemon Squeezy.
130
+ * POST https://api.lemonsqueezy.com/v1/licenses/activate
131
+ * Params: license_key, instance_name
132
+ * Returns instance_id on success.
123
133
  */
124
- async function validateWithLS(key) {
134
+ async function activateWithLS(key) {
125
135
  try {
126
136
  const controller = new AbortController();
127
137
  const timeout = setTimeout(() => controller.abort(), 8000);
128
138
 
129
- const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
139
+ const body = new URLSearchParams({
140
+ license_key: key,
141
+ instance_name: `seo-intel-${getMachineId()}`,
142
+ });
143
+
144
+ const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', {
130
145
  signal: controller.signal,
131
146
  method: 'POST',
132
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
133
- body: JSON.stringify({
134
- license_key: key,
135
- instance_name: `seo-intel-${getMachineId()}`,
136
- }),
147
+ headers: { 'Accept': 'application/json' },
148
+ body,
137
149
  });
138
150
 
139
151
  clearTimeout(timeout);
140
152
  const data = await res.json();
141
153
 
142
- if (data.valid) {
143
- // Determine tier from LS metadata
144
- // Convention: product variant name contains "solo" or "agency"
145
- const variantName = (data.meta?.variant_name || data.license_key?.key_data?.variant || '').toLowerCase();
146
- const tier = variantName.includes('agency') ? 'agency' : 'solo';
147
- return { valid: true, tier };
154
+ if (data.activated) {
155
+ const variantName = (data.meta?.variant_name || '').toLowerCase();
156
+ const tier = getTierFromVariantName(variantName);
157
+ return {
158
+ valid: true,
159
+ tier,
160
+ instanceId: data.instance?.id || null,
161
+ meta: data.meta,
162
+ };
163
+ }
164
+
165
+ // Already at activation limit? Try validate instead (might be same machine re-activating)
166
+ if (data.error && data.error.includes('activation limit')) {
167
+ return { valid: false, error: data.error, activationLimitReached: true };
148
168
  }
149
169
 
150
- return { valid: false, error: data.error || 'License key not valid' };
170
+ return { valid: false, error: data.error || 'Activation failed' };
151
171
  } catch (err) {
152
172
  return { valid: false, error: `Network error: ${err.message}`, offline: true };
153
173
  }
154
174
  }
155
175
 
156
- // ── Froggo Token Validation ────────────────────────────────────────────────
157
-
158
176
  /**
159
- * Validate a Froggo marketplace token.
160
- * Returns { valid, tier, error? } — never throws.
177
+ * Validate a license key against Lemon Squeezy.
178
+ * POST https://api.lemonsqueezy.com/v1/licenses/validate
179
+ * Params: license_key, instance_id (optional)
161
180
  */
162
- async function validateWithFroggo(token) {
181
+ async function validateWithLS(key, instanceId) {
163
182
  try {
164
183
  const controller = new AbortController();
165
184
  const timeout = setTimeout(() => controller.abort(), 8000);
166
185
 
167
- const res = await fetch('https://api.froggo.pro/v1/licenses/validate', {
186
+ const params = { license_key: key };
187
+ if (instanceId) params.instance_id = instanceId;
188
+ const body = new URLSearchParams(params);
189
+
190
+ const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
168
191
  signal: controller.signal,
169
192
  method: 'POST',
170
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
171
- body: JSON.stringify({
172
- token,
173
- product: 'seo-intel',
174
- }),
193
+ headers: { 'Accept': 'application/json' },
194
+ body,
175
195
  });
176
196
 
177
197
  clearTimeout(timeout);
178
198
  const data = await res.json();
179
199
 
180
200
  if (data.valid) {
181
- const tier = (data.tier || 'solo').toLowerCase();
182
- return { valid: true, tier: tier === 'agency' ? 'agency' : 'solo' };
201
+ const variantName = (data.meta?.variant_name || '').toLowerCase();
202
+ const tier = getTierFromVariantName(variantName);
203
+ return {
204
+ valid: true,
205
+ tier,
206
+ instanceId: data.instance?.id || instanceId,
207
+ status: data.license_key?.status,
208
+ meta: data.meta,
209
+ };
183
210
  }
184
211
 
185
- return { valid: false, error: data.error || 'Token not valid' };
212
+ return { valid: false, error: data.error || 'License key not valid', status: data.license_key?.status };
186
213
  } catch (err) {
187
214
  return { valid: false, error: `Network error: ${err.message}`, offline: true };
188
215
  }
189
216
  }
190
217
 
218
+ /**
219
+ * Deactivate a license key instance.
220
+ * POST https://api.lemonsqueezy.com/v1/licenses/deactivate
221
+ * Params: license_key, instance_id
222
+ */
223
+ export async function deactivateLicense() {
224
+ const keyInfo = readKeyFromEnv();
225
+ if (!keyInfo) return { deactivated: false, error: 'No license key found' };
226
+
227
+ const cache = readCache();
228
+ const instanceId = cache?.instanceId;
229
+ if (!instanceId) return { deactivated: false, error: 'No active instance to deactivate' };
230
+
231
+ try {
232
+ const controller = new AbortController();
233
+ const timeout = setTimeout(() => controller.abort(), 8000);
234
+
235
+ const body = new URLSearchParams({
236
+ license_key: keyInfo.value,
237
+ instance_id: instanceId,
238
+ });
239
+
240
+ const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/deactivate', {
241
+ signal: controller.signal,
242
+ method: 'POST',
243
+ headers: { 'Accept': 'application/json' },
244
+ body,
245
+ });
246
+
247
+ clearTimeout(timeout);
248
+ const data = await res.json();
249
+
250
+ if (data.deactivated) {
251
+ // Clear local cache
252
+ writeCache({});
253
+ _cachedLicense = undefined;
254
+ return { deactivated: true };
255
+ }
256
+
257
+ return { deactivated: false, error: data.error || 'Deactivation failed' };
258
+ } catch (err) {
259
+ return { deactivated: false, error: `Network error: ${err.message}` };
260
+ }
261
+ }
262
+
191
263
  // ── License Loading ─────────────────────────────────────────────────────────
192
264
 
193
265
  let _cachedLicense = undefined;
194
266
 
195
267
  /**
196
- * Read the license key / token from environment or .env file.
268
+ * Read the license key from environment or .env file.
197
269
  * Returns { type, value } or null.
198
270
  */
199
271
  function readKeyFromEnv() {
200
- // 1. Check Froggo token first (marketplace priority)
201
- let froggoToken = process.env.FROGGO_TOKEN;
202
272
  let lsKey = process.env.SEO_INTEL_LICENSE;
203
273
 
204
- // 2. Check .env file
205
- if (!froggoToken || !lsKey) {
274
+ // Check .env file
275
+ if (!lsKey) {
206
276
  const envPath = join(ROOT, '.env');
207
277
  if (existsSync(envPath)) {
208
278
  try {
209
279
  const content = readFileSync(envPath, 'utf8');
210
- if (!froggoToken) {
211
- const match = content.match(/^FROGGO_TOKEN=(.+)$/m);
212
- if (match) froggoToken = match[1].trim().replace(/^["']|["']$/g, '');
213
- }
214
- if (!lsKey) {
215
- const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
216
- if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
217
- }
280
+ const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
281
+ if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
218
282
  } catch { /* ok */ }
219
283
  }
220
284
  }
221
285
 
222
- if (froggoToken) return { type: 'froggo', value: froggoToken };
223
286
  if (lsKey) return { type: 'lemon-squeezy', value: lsKey };
224
287
  return null;
225
288
  }
@@ -260,7 +323,13 @@ export function loadLicense() {
260
323
  }
261
324
 
262
325
  /**
263
- * Async license activation — validates against remote API and caches result.
326
+ * Async license activation — validates against Lemon Squeezy and caches result.
327
+ *
328
+ * Flow:
329
+ * 1. If we have a cached instanceId → validate with it
330
+ * 2. If no instanceId → activate (creates new instance)
331
+ * 3. If activation limit reached → validate without instanceId (key-level check)
332
+ *
264
333
  * Call this at startup or when loadLicense() returns needsActivation: true.
265
334
  * Returns the validated license object.
266
335
  */
@@ -273,34 +342,46 @@ export async function activateLicense() {
273
342
  return _cachedLicense;
274
343
  }
275
344
 
345
+ // Check if we already have an instanceId from a previous activation
346
+ const cache = readCache();
347
+ const existingInstanceId = (cache && cache.key === keyInfo.value) ? cache.instanceId : null;
348
+
276
349
  let result;
277
- if (keyInfo.type === 'froggo') {
278
- result = await validateWithFroggo(keyInfo.value);
350
+
351
+ if (existingInstanceId) {
352
+ // We have a stored instance — validate it
353
+ result = await validateWithLS(keyInfo.value, existingInstanceId);
279
354
  } else {
280
- result = await validateWithLS(keyInfo.value);
355
+ // No stored instance — try to activate
356
+ result = await activateWithLS(keyInfo.value);
357
+
358
+ // If activation limit reached, fall back to key-level validate
359
+ if (!result.valid && result.activationLimitReached) {
360
+ result = await validateWithLS(keyInfo.value);
361
+ }
281
362
  }
282
363
 
283
364
  if (result.valid) {
284
- // Cache the successful validation
365
+ // Cache the successful validation with instanceId
285
366
  writeCache({
286
367
  key: keyInfo.value,
287
368
  tier: result.tier,
288
369
  validatedAt: Date.now(),
289
- source: keyInfo.type,
370
+ source: 'lemon-squeezy',
371
+ instanceId: result.instanceId || existingInstanceId,
290
372
  machineId: getMachineId(),
291
373
  });
292
374
 
293
375
  const tierData = TIERS[result.tier] || TIERS.solo;
294
- _cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source: keyInfo.type, ...tierData };
376
+ _cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source: 'lemon-squeezy', ...tierData };
295
377
  return _cachedLicense;
296
378
  }
297
379
 
298
380
  if (result.offline) {
299
381
  // Network error — check if we have any stale cache at all
300
- const cache = readCache();
301
382
  if (cache && cache.key === keyInfo.value) {
302
383
  const tierData = TIERS[cache.tier] || TIERS.solo;
303
- _cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source: cache.source, stale: true, ...tierData };
384
+ _cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source: 'lemon-squeezy', stale: true, ...tierData };
304
385
  return _cachedLicense;
305
386
  }
306
387
  }
package/lib/updater.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Two update channels:
8
8
  * - npm registry (public installs via `npm install -g seo-intel`)
9
- * - froggo.pro (direct downloads / pro users)
9
+ * - ukkometa.fi (direct downloads / pro users)
10
10
  *
11
11
  * Usage:
12
12
  * import { checkForUpdates, printUpdateNotice } from './updater.js';
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
20
+ import { homedir } from 'os';
20
21
  import { join, dirname } from 'path';
21
22
  import { fileURLToPath } from 'url';
22
23
 
@@ -40,8 +41,8 @@ export function getCurrentVersion() {
40
41
 
41
42
  // ── Cache file ─────────────────────────────────────────────────────────────
42
43
 
43
- const CACHE_DIR = join(ROOT, '.cache');
44
- const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
44
+ const CACHE_DIR = join(homedir(), '.seo-intel');
45
+ const CACHE_FILE = join(CACHE_DIR, 'update-cache.json');
45
46
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
46
47
 
47
48
  function readCache() {
@@ -113,15 +114,15 @@ async function checkNpm() {
113
114
  }
114
115
 
115
116
  /**
116
- * Check froggo.pro for latest version.
117
+ * Check ukkometa.fi for latest version.
117
118
  * Endpoint returns { version, changelog?, downloadUrl? }
118
119
  */
119
- async function checkFroggo() {
120
+ async function checkUkkometa() {
120
121
  const controller = new AbortController();
121
122
  const timeout = setTimeout(() => controller.abort(), 5000);
122
123
 
123
124
  try {
124
- const res = await fetch('https://froggo.pro/api/seo-intel/version', {
125
+ const res = await fetch('https://ukkometa.fi/api/seo-intel/version', {
125
126
  signal: controller.signal,
126
127
  headers: { 'Accept': 'application/json' },
127
128
  });
@@ -165,12 +166,12 @@ export function checkForUpdates() {
165
166
  const current = getCurrentVersion();
166
167
 
167
168
  // Check both sources in parallel
168
- const [npmVersion, froggoData] = await Promise.all([
169
+ const [npmVersion, ukkometaData] = await Promise.all([
169
170
  checkNpm(),
170
- checkFroggo(),
171
+ checkUkkometa(),
171
172
  ]);
172
173
 
173
- const froggoVersion = froggoData?.version || null;
174
+ const ukkometaVersion = ukkometaData?.version || null;
174
175
 
175
176
  // Determine the highest available version
176
177
  let latestVersion = current;
@@ -182,11 +183,11 @@ export function checkForUpdates() {
182
183
  latestVersion = npmVersion;
183
184
  source = 'npm';
184
185
  }
185
- if (froggoVersion && compareSemver(froggoVersion, latestVersion) > 0) {
186
- latestVersion = froggoVersion;
187
- source = 'froggo';
188
- changelog = froggoData.changelog;
189
- downloadUrl = froggoData.downloadUrl;
186
+ if (ukkometaVersion && compareSemver(ukkometaVersion, latestVersion) > 0) {
187
+ latestVersion = ukkometaVersion;
188
+ source = 'ukkometa';
189
+ changelog = ukkometaData.changelog;
190
+ downloadUrl = ukkometaData.downloadUrl;
190
191
  }
191
192
 
192
193
  const hasUpdate = compareSemver(latestVersion, current) > 0;
@@ -199,10 +200,10 @@ export function checkForUpdates() {
199
200
  changelog,
200
201
  downloadUrl,
201
202
  npmVersion,
202
- froggoVersion,
203
- security: froggoData?.security || false,
204
- securitySeverity: froggoData?.securitySeverity || null,
205
- updatePolicy: froggoData?.updatePolicy || null,
203
+ ukkometaVersion,
204
+ security: ukkometaData?.security || false,
205
+ securitySeverity: ukkometaData?.securitySeverity || null,
206
+ updatePolicy: ukkometaData?.updatePolicy || null,
206
207
  };
207
208
 
208
209
  writeCache(_updateResult);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -56,7 +56,8 @@
56
56
  "Start SEO Intel.command",
57
57
  "Setup SEO Intel.command",
58
58
  "Start SEO Intel.bat",
59
- "start-seo-intel.sh"
59
+ "start-seo-intel.sh",
60
+ "CHANGELOG.md"
60
61
  ],
61
62
  "scripts": {
62
63
  "crawl": "node cli.js crawl",
package/seo-audit.js CHANGED
@@ -507,7 +507,7 @@ async function generateReport(rawUrl) {
507
507
 
508
508
  lines.push(`# SEO Audit Report — ${rawUrl}`);
509
509
  lines.push(`**Date:** ${date} `);
510
- lines.push(`**Tool:** SEO Intel (froggo.pro)\n`);
510
+ lines.push(`**Tool:** SEO Intel (ukkometa.fi)\n`);
511
511
  lines.push(`---\n`);
512
512
 
513
513
  // Score card
@@ -596,7 +596,7 @@ async function generateReport(rawUrl) {
596
596
  }
597
597
  lines.push('');
598
598
  lines.push(`---\n`);
599
- lines.push(`*Generated by SEO Intel — froggo.pro*`);
599
+ lines.push(`*Generated by SEO Intel — ukkometa.fi*`);
600
600
 
601
601
  const md = lines.join('\n');
602
602
  writeFileSync(outFile, md);
package/server.js CHANGED
@@ -501,13 +501,11 @@ async function handleRequest(req, res) {
501
501
  envContent = readFileSync(envPath, 'utf8');
502
502
  }
503
503
 
504
- // Determine key type
505
- const isFroggo = key.startsWith('FROGGO_') || key.length > 60;
506
- const envVar = isFroggo ? 'FROGGO_TOKEN' : 'SEO_INTEL_LICENSE';
504
+ const envVar = 'SEO_INTEL_LICENSE';
507
505
 
508
- // Remove existing lines for both key types
506
+ // Remove existing license line
509
507
  const lines = envContent.split('\n').filter(l =>
510
- !l.startsWith('SEO_INTEL_LICENSE=') && !l.startsWith('FROGGO_TOKEN=')
508
+ !l.startsWith('SEO_INTEL_LICENSE=')
511
509
  );
512
510
  lines.push(`${envVar}=${key}`);
513
511