@rankcli/cli 0.0.29 → 0.0.31
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/README.md +88 -21
- package/bin/rankcli.cjs +18 -0
- package/dist/index.js +715 -269
- package/dist/index.mjs +715 -269
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -41,22 +41,23 @@ var import_open = __toESM(require("open"));
|
|
|
41
41
|
|
|
42
42
|
// src/lib/config.ts
|
|
43
43
|
var import_conf = __toESM(require("conf"));
|
|
44
|
+
var import_node_crypto = require("crypto");
|
|
44
45
|
var import_supabase_js = require("@supabase/supabase-js");
|
|
45
46
|
var INJECTED_CONFIG = {"supabaseUrl":"https://bspljbxwbjiqueeyzyat.supabase.co","supabaseAnonKey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJzcGxqYnh3YmppcXVlZXl6eWF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNzQ1NTksImV4cCI6MjA4NDg1MDU1OX0.7gprxM2gT9cZ99sjZURLltrln78dEnwyFFKYpTzBaUU","webAppUrl":"https://rankcli.dev","decryptPassphrase":"lKFLeIw/xGPa6E8c4XswZX/oiLtJf/yLF/Y6zwovte8="};
|
|
46
47
|
function getSupabaseUrl() {
|
|
47
|
-
const url = process.env.RANKCLI_SUPABASE_URL ||
|
|
48
|
+
const url = process.env.RANKCLI_SUPABASE_URL || INJECTED_CONFIG?.supabaseUrl;
|
|
48
49
|
if (!url) {
|
|
49
50
|
throw new Error(
|
|
50
|
-
"Supabase URL not configured. Set RANKCLI_SUPABASE_URL environment variable.\nSee: https://rankcli.dev/docs
|
|
51
|
+
"Supabase URL not configured. Set RANKCLI_SUPABASE_URL environment variable.\nSee: https://rankcli.dev/docs#getting-started"
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
return url;
|
|
54
55
|
}
|
|
55
56
|
function getSupabaseAnonKey() {
|
|
56
|
-
const key = process.env.RANKCLI_SUPABASE_ANON_KEY ||
|
|
57
|
+
const key = process.env.RANKCLI_SUPABASE_ANON_KEY || INJECTED_CONFIG?.supabaseAnonKey;
|
|
57
58
|
if (!key) {
|
|
58
59
|
throw new Error(
|
|
59
|
-
"Supabase anon key not configured. Set RANKCLI_SUPABASE_ANON_KEY environment variable.\nSee: https://rankcli.dev/docs
|
|
60
|
+
"Supabase anon key not configured. Set RANKCLI_SUPABASE_ANON_KEY environment variable.\nSee: https://rankcli.dev/docs#getting-started"
|
|
60
61
|
);
|
|
61
62
|
}
|
|
62
63
|
return key;
|
|
@@ -80,6 +81,7 @@ function getApiUrl() {
|
|
|
80
81
|
}
|
|
81
82
|
var config = new import_conf.default({
|
|
82
83
|
projectName: "rankcli",
|
|
84
|
+
...process.env.RANKCLI_CONFIG_DIR ? { cwd: process.env.RANKCLI_CONFIG_DIR } : {},
|
|
83
85
|
schema: {
|
|
84
86
|
accessToken: { type: "string" },
|
|
85
87
|
refreshToken: { type: "string" },
|
|
@@ -90,7 +92,9 @@ var config = new import_conf.default({
|
|
|
90
92
|
apiKeyName: { type: "string" },
|
|
91
93
|
planId: { type: "string" },
|
|
92
94
|
planName: { type: "string" },
|
|
93
|
-
sitesLimit: { type: "number" }
|
|
95
|
+
sitesLimit: { type: "number" },
|
|
96
|
+
planKeyFingerprint: { type: "string" },
|
|
97
|
+
planCheckedAt: { type: "number" }
|
|
94
98
|
}
|
|
95
99
|
});
|
|
96
100
|
var supabaseClient = null;
|
|
@@ -141,15 +145,17 @@ async function validateApiKey(apiKey) {
|
|
|
141
145
|
method: "POST",
|
|
142
146
|
headers: {
|
|
143
147
|
"Content-Type": "application/json",
|
|
144
|
-
"X-API-Key": apiKey
|
|
148
|
+
"X-API-Key": apiKey,
|
|
149
|
+
"X-Endpoint": "cli"
|
|
145
150
|
}
|
|
146
151
|
});
|
|
147
|
-
const data = await response.json();
|
|
152
|
+
const data = await response.json().catch(() => ({}));
|
|
148
153
|
if (!response.ok) {
|
|
149
|
-
return { valid: false, error: data.error ||
|
|
154
|
+
return { valid: false, status: response.status, error: data.error || `Invalid API key (HTTP ${response.status})` };
|
|
150
155
|
}
|
|
151
156
|
return {
|
|
152
157
|
valid: true,
|
|
158
|
+
status: response.status,
|
|
153
159
|
user: data.user,
|
|
154
160
|
subscription: data.subscription
|
|
155
161
|
};
|
|
@@ -157,6 +163,50 @@ async function validateApiKey(apiKey) {
|
|
|
157
163
|
return { valid: false, error: err instanceof Error ? err.message : "Validation failed" };
|
|
158
164
|
}
|
|
159
165
|
}
|
|
166
|
+
function keyFingerprint(apiKey) {
|
|
167
|
+
return (0, import_node_crypto.createHash)("sha256").update(apiKey).digest("hex").slice(0, 16);
|
|
168
|
+
}
|
|
169
|
+
var PLAN_CACHE_MS = 6 * 60 * 60 * 1e3;
|
|
170
|
+
async function ensureApiKeyPlan(options = {}) {
|
|
171
|
+
if (!isApiKeyAuth()) return null;
|
|
172
|
+
const apiKey = getApiKey();
|
|
173
|
+
const fingerprint = keyFingerprint(apiKey);
|
|
174
|
+
const sameKey = config.get("planKeyFingerprint") === fingerprint;
|
|
175
|
+
const fresh = sameKey && Date.now() - (config.get("planCheckedAt") || 0) < PLAN_CACHE_MS;
|
|
176
|
+
if (fresh && !options.force) {
|
|
177
|
+
return { status: "ok", planId: config.get("planId"), planName: config.get("planName") || "Free", email: config.get("email"), cached: true };
|
|
178
|
+
}
|
|
179
|
+
const result = await validateApiKey(apiKey);
|
|
180
|
+
if (result.valid) {
|
|
181
|
+
config.set("planKeyFingerprint", fingerprint);
|
|
182
|
+
config.set("planCheckedAt", Date.now());
|
|
183
|
+
if (result.user?.email) config.set("email", result.user.email);
|
|
184
|
+
if (result.subscription?.planId && result.subscription.planId !== "free") {
|
|
185
|
+
saveSubscription(result.subscription);
|
|
186
|
+
} else {
|
|
187
|
+
setFreePlan();
|
|
188
|
+
}
|
|
189
|
+
return { status: "ok", planId: config.get("planId"), planName: config.get("planName") || "Free", email: result.user?.email, cached: false };
|
|
190
|
+
}
|
|
191
|
+
if (result.status === 403) {
|
|
192
|
+
config.set("planKeyFingerprint", fingerprint);
|
|
193
|
+
config.set("planCheckedAt", Date.now());
|
|
194
|
+
setFreePlan();
|
|
195
|
+
return { status: "free" };
|
|
196
|
+
}
|
|
197
|
+
if (result.status === 401) {
|
|
198
|
+
config.delete("planKeyFingerprint");
|
|
199
|
+
setFreePlan();
|
|
200
|
+
return { status: "invalid", error: result.error || "Invalid API key" };
|
|
201
|
+
}
|
|
202
|
+
if (!sameKey) setFreePlan();
|
|
203
|
+
return { status: "unavailable", error: result.error || "Validation failed" };
|
|
204
|
+
}
|
|
205
|
+
function setFreePlan() {
|
|
206
|
+
config.delete("planId");
|
|
207
|
+
config.set("planName", "Free");
|
|
208
|
+
config.set("sitesLimit", 1);
|
|
209
|
+
}
|
|
160
210
|
async function getSession() {
|
|
161
211
|
const accessToken = config.get("accessToken");
|
|
162
212
|
const refreshToken = config.get("refreshToken");
|
|
@@ -216,6 +266,8 @@ function clearCredentials() {
|
|
|
216
266
|
config.delete("planId");
|
|
217
267
|
config.delete("planName");
|
|
218
268
|
config.delete("sitesLimit");
|
|
269
|
+
config.delete("planKeyFingerprint");
|
|
270
|
+
config.delete("planCheckedAt");
|
|
219
271
|
}
|
|
220
272
|
function getUserInfo() {
|
|
221
273
|
return {
|
|
@@ -250,7 +302,7 @@ async function browserLogin(spinner) {
|
|
|
250
302
|
} catch (err) {
|
|
251
303
|
spinner.fail("Could not start login");
|
|
252
304
|
console.log(import_chalk.default.red(err instanceof Error ? err.message : "Network error"));
|
|
253
|
-
return;
|
|
305
|
+
return false;
|
|
254
306
|
}
|
|
255
307
|
spinner.text = "Opening browser to approve...";
|
|
256
308
|
try {
|
|
@@ -261,6 +313,10 @@ async function browserLogin(spinner) {
|
|
|
261
313
|
If a browser didn't open, visit:
|
|
262
314
|
${start.url}
|
|
263
315
|
`));
|
|
316
|
+
if (start.userCode) {
|
|
317
|
+
console.log(`Approve in your browser only if it shows this code: ${import_chalk.default.bold(start.userCode)}
|
|
318
|
+
`);
|
|
319
|
+
}
|
|
264
320
|
const deadline = Date.now() + start.expiresInSeconds * 1e3;
|
|
265
321
|
spinner.text = "Waiting for approval in your browser...";
|
|
266
322
|
while (Date.now() < deadline) {
|
|
@@ -278,58 +334,75 @@ ${start.url}
|
|
|
278
334
|
}
|
|
279
335
|
if (status.status === "claimed" && status.apiKey) {
|
|
280
336
|
saveApiKey(status.apiKey, status.apiKeyName, status.email);
|
|
281
|
-
await
|
|
337
|
+
await ensureApiKeyPlan({ force: true });
|
|
282
338
|
spinner.succeed(`Logged in as ${import_chalk.default.green(status.email)}`);
|
|
283
|
-
return;
|
|
339
|
+
return true;
|
|
284
340
|
}
|
|
285
341
|
if (status.status === "expired" || status.status === "not_found") {
|
|
286
342
|
spinner.fail("Login link expired");
|
|
287
343
|
console.log(import_chalk.default.dim('Run "rankcli login" again.'));
|
|
288
|
-
return;
|
|
344
|
+
return false;
|
|
289
345
|
}
|
|
290
346
|
}
|
|
291
347
|
spinner.fail("Timed out waiting for approval");
|
|
292
348
|
console.log(import_chalk.default.dim(`Run "rankcli login" again when you're ready.`));
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
async function verifyApiKey(spinner, source) {
|
|
352
|
+
spinner.start("Validating API key...");
|
|
353
|
+
const plan = await ensureApiKeyPlan({ force: true });
|
|
354
|
+
if (!plan || plan.status === "invalid") {
|
|
355
|
+
spinner.fail("Invalid API key");
|
|
356
|
+
console.log(import_chalk.default.red(`
|
|
357
|
+
${plan?.error || "The key was rejected"}`));
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
if (plan.status === "unavailable") {
|
|
361
|
+
spinner.fail("Could not validate API key");
|
|
362
|
+
console.log(import_chalk.default.red(`
|
|
363
|
+
${plan.error}`));
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
spinner.succeed(`Authenticated via ${source}`);
|
|
367
|
+
const { email, planName } = getUserInfo();
|
|
368
|
+
if (email) console.log(import_chalk.default.dim(`
|
|
369
|
+
Email: ${email}`));
|
|
370
|
+
console.log(import_chalk.default.dim(`${email ? "" : "\n"}Plan: ${planName || "Free"}`));
|
|
371
|
+
if (plan.status === "free") {
|
|
372
|
+
console.log(import_chalk.default.dim("The key works for dashboard sync; the paid checks and API features need a paid plan."));
|
|
373
|
+
}
|
|
374
|
+
return 0;
|
|
293
375
|
}
|
|
294
376
|
async function login(options) {
|
|
377
|
+
const spinner = (0, import_ora.default)();
|
|
378
|
+
if (options.token !== void 0) {
|
|
379
|
+
const apiKey = options.token.trim();
|
|
380
|
+
if (!apiKey.startsWith("rankcli_")) {
|
|
381
|
+
console.log(import_chalk.default.red(apiKey ? 'Invalid API key format. Keys should start with "rankcli_"' : "Empty API key - is the RANKCLI_API_KEY secret set?"));
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
if (process.env.RANKCLI_API_KEY && process.env.RANKCLI_API_KEY !== apiKey) {
|
|
385
|
+
console.log(import_chalk.default.red("RANKCLI_API_KEY is set to a different key and would take precedence over --token. Unset it or pass the same key."));
|
|
386
|
+
return 1;
|
|
387
|
+
}
|
|
388
|
+
clearCredentials();
|
|
389
|
+
saveApiKey(apiKey);
|
|
390
|
+
const code = await verifyApiKey(spinner, "API key");
|
|
391
|
+
if (code !== 0) clearCredentials();
|
|
392
|
+
return code;
|
|
393
|
+
}
|
|
394
|
+
if (process.env.RANKCLI_API_KEY) {
|
|
395
|
+
return verifyApiKey(spinner, "RANKCLI_API_KEY");
|
|
396
|
+
}
|
|
295
397
|
if (isLoggedIn()) {
|
|
296
398
|
const { email } = getUserInfo();
|
|
297
399
|
console.log(import_chalk.default.yellow(`Already logged in as ${email}`));
|
|
298
400
|
console.log(import_chalk.default.dim('Run "rankcli logout" to sign out first.'));
|
|
299
|
-
return;
|
|
401
|
+
return 0;
|
|
300
402
|
}
|
|
301
|
-
const spinner = (0, import_ora.default)();
|
|
302
403
|
try {
|
|
303
|
-
if (options.token) {
|
|
304
|
-
const apiKey = options.token;
|
|
305
|
-
if (!apiKey.startsWith("rankcli_")) {
|
|
306
|
-
console.log(import_chalk.default.red('Invalid API key format. Keys should start with "rankcli_"'));
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
spinner.start("Validating API key...");
|
|
310
|
-
const result = await validateApiKey(apiKey);
|
|
311
|
-
if (!result.valid) {
|
|
312
|
-
spinner.fail("Invalid API key");
|
|
313
|
-
console.log(import_chalk.default.red(`
|
|
314
|
-
${result.error}`));
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
saveApiKey(apiKey);
|
|
318
|
-
if (result.subscription) {
|
|
319
|
-
saveSubscription({
|
|
320
|
-
planId: result.subscription.planId,
|
|
321
|
-
planName: result.subscription.planName,
|
|
322
|
-
sitesLimit: result.subscription.sitesLimit
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
spinner.succeed(`Authenticated via API key`);
|
|
326
|
-
console.log(import_chalk.default.dim(`
|
|
327
|
-
Email: ${result.user?.email}`));
|
|
328
|
-
console.log(import_chalk.default.dim(`Plan: ${result.subscription?.planName || "Free"}`));
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
404
|
if (options.browser !== false && !options.email) {
|
|
332
|
-
await browserLogin(spinner);
|
|
405
|
+
return await browserLogin(spinner) ? 0 : 1;
|
|
333
406
|
} else {
|
|
334
407
|
let email = options.email;
|
|
335
408
|
if (!email) {
|
|
@@ -368,11 +441,11 @@ ${error.message}`));
|
|
|
368
441
|
console.log(import_chalk.default.dim("\nDon't have an account? Sign up at:"));
|
|
369
442
|
console.log(import_chalk.default.cyan(` ${WEB_APP_URL}/signup`));
|
|
370
443
|
}
|
|
371
|
-
return;
|
|
444
|
+
return 1;
|
|
372
445
|
}
|
|
373
446
|
if (!data.session) {
|
|
374
447
|
spinner.fail("No session returned");
|
|
375
|
-
return;
|
|
448
|
+
return 1;
|
|
376
449
|
}
|
|
377
450
|
saveSession(data.session);
|
|
378
451
|
await fetchAndSaveSubscription();
|
|
@@ -380,10 +453,12 @@ ${error.message}`));
|
|
|
380
453
|
const { planName, sitesLimit } = getUserInfo();
|
|
381
454
|
console.log(import_chalk.default.dim(`
|
|
382
455
|
Plan: ${planName} (${sitesLimit} site${sitesLimit !== 1 ? "s" : ""})`));
|
|
456
|
+
return 0;
|
|
383
457
|
}
|
|
384
458
|
} catch (err) {
|
|
385
459
|
spinner.fail("Login failed");
|
|
386
460
|
console.error(import_chalk.default.red(err instanceof Error ? err.message : "Unknown error"));
|
|
461
|
+
return 1;
|
|
387
462
|
}
|
|
388
463
|
}
|
|
389
464
|
async function logout() {
|
|
@@ -413,16 +488,10 @@ async function whoami() {
|
|
|
413
488
|
const spinner = (0, import_ora.default)("Checking account...").start();
|
|
414
489
|
try {
|
|
415
490
|
if (isApiKeyAuth()) {
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
saveSubscription({
|
|
421
|
-
planId: result.subscription.planId,
|
|
422
|
-
planName: result.subscription.planName,
|
|
423
|
-
sitesLimit: result.subscription.sitesLimit
|
|
424
|
-
});
|
|
425
|
-
}
|
|
491
|
+
const plan = await ensureApiKeyPlan({ force: true });
|
|
492
|
+
if (plan?.status === "invalid") {
|
|
493
|
+
spinner.fail(`API key rejected: ${plan.error}`);
|
|
494
|
+
return;
|
|
426
495
|
}
|
|
427
496
|
} else {
|
|
428
497
|
await fetchAndSaveSubscription();
|
|
@@ -494,7 +563,10 @@ async function setup(options) {
|
|
|
494
563
|
await setupGSC(projectPath, options.gsc);
|
|
495
564
|
}
|
|
496
565
|
if (options.githubAction) {
|
|
497
|
-
|
|
566
|
+
if (!options.url) {
|
|
567
|
+
console.log("\u2139\uFE0F No --url given - the workflow audits https://example.com until you edit SITE_URL in it.\n");
|
|
568
|
+
}
|
|
569
|
+
await setupGitHubAction(projectPath, options.schedule || "weekly", options.url, options.sync);
|
|
498
570
|
}
|
|
499
571
|
}
|
|
500
572
|
async function interactiveSetup(projectPath) {
|
|
@@ -627,9 +699,12 @@ async function setupGitHubAction(projectPath, schedule, siteUrl, syncToDashboard
|
|
|
627
699
|
siteUrl: siteUrl || "https://example.com",
|
|
628
700
|
features: {
|
|
629
701
|
audit: true,
|
|
630
|
-
|
|
702
|
+
// `rankcli fix --auto` needs a Solo+ plan, which the workflow can
|
|
703
|
+
// only prove with a RANKCLI_API_KEY secret - without one the step
|
|
704
|
+
// is always tier-blocked, so don't generate it.
|
|
705
|
+
autoFix: !!syncToDashboard,
|
|
631
706
|
createIssues: true,
|
|
632
|
-
createPRs:
|
|
707
|
+
createPRs: !!syncToDashboard
|
|
633
708
|
},
|
|
634
709
|
syncToDashboard
|
|
635
710
|
};
|
|
@@ -1035,23 +1110,25 @@ var import_chalk4 = __toESM(require("chalk"));
|
|
|
1035
1110
|
var import_chalk3 = __toESM(require("chalk"));
|
|
1036
1111
|
var WEB_APP_URL2 = "https://rankcli.dev";
|
|
1037
1112
|
var TIER_LIMITS = {
|
|
1113
|
+
// Every plan runs every check (pricing-tiers.json: free_audit / free_registered 280). Plans
|
|
1114
|
+
// differ in sites, schedules, pages per audit, PRs and alerts - not in what an audit checks.
|
|
1038
1115
|
free_anon: {
|
|
1039
|
-
checks:
|
|
1116
|
+
checks: 280,
|
|
1040
1117
|
sites: 0,
|
|
1041
1118
|
domains_per_month: 1,
|
|
1042
|
-
features: { geo:
|
|
1119
|
+
features: { geo: true, auto_fix: true, github: false, ai: false }
|
|
1043
1120
|
},
|
|
1044
1121
|
free: {
|
|
1045
|
-
checks:
|
|
1122
|
+
checks: 280,
|
|
1046
1123
|
sites: 1,
|
|
1047
1124
|
domains_per_month: 2,
|
|
1048
|
-
features: { geo:
|
|
1125
|
+
features: { geo: true, auto_fix: true, github: true, ai: false }
|
|
1049
1126
|
},
|
|
1050
1127
|
solo: {
|
|
1051
1128
|
checks: 280,
|
|
1052
1129
|
sites: 3,
|
|
1053
1130
|
domains_per_month: 5,
|
|
1054
|
-
features: { geo: true, auto_fix: true, github:
|
|
1131
|
+
features: { geo: true, auto_fix: true, github: true, ai: true }
|
|
1055
1132
|
},
|
|
1056
1133
|
pro: {
|
|
1057
1134
|
checks: 280,
|
|
@@ -1067,26 +1144,24 @@ var TIER_LIMITS = {
|
|
|
1067
1144
|
features: { geo: true, auto_fix: true, github: true, ai: true }
|
|
1068
1145
|
}
|
|
1069
1146
|
};
|
|
1147
|
+
function tierFromPlanId(planId) {
|
|
1148
|
+
if (!planId) return void 0;
|
|
1149
|
+
const base = planId.toLowerCase().replace(/^ltd_/, "").replace(/_(annual|yearly|monthly|founding|lifetime|ltd)$/, "");
|
|
1150
|
+
return base === "free" || base === "solo" || base === "pro" || base === "agency" ? base : void 0;
|
|
1151
|
+
}
|
|
1070
1152
|
var UPSELL_MESSAGES = {
|
|
1071
1153
|
// After free audit completes
|
|
1072
1154
|
free_audit_complete: () => [
|
|
1073
1155
|
"",
|
|
1074
1156
|
import_chalk3.default.yellow("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"),
|
|
1075
|
-
import_chalk3.default.yellow.bold(" \u{1F680}
|
|
1076
|
-
import_chalk3.default.dim("
|
|
1157
|
+
import_chalk3.default.yellow.bold(" \u{1F680} Keep this site audited, free"),
|
|
1158
|
+
import_chalk3.default.dim(" A free account audits one site weekly, keeps the history and"),
|
|
1159
|
+
import_chalk3.default.dim(" emails you a weekly report."),
|
|
1077
1160
|
"",
|
|
1078
1161
|
import_chalk3.default.cyan(` \u2192 rankcli login`),
|
|
1079
1162
|
import_chalk3.default.yellow("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"),
|
|
1080
1163
|
""
|
|
1081
1164
|
],
|
|
1082
|
-
// When checks limit is reached
|
|
1083
|
-
checks_limit: (ctx) => [
|
|
1084
|
-
"",
|
|
1085
|
-
import_chalk3.default.yellow(`\u26A0\uFE0F Running ${ctx.current || 50} of ${TIER_LIMITS.solo.checks} available checks`),
|
|
1086
|
-
import_chalk3.default.dim(` Unlock all ${TIER_LIMITS.solo.checks}+ checks with Solo ($9/mo)`),
|
|
1087
|
-
import_chalk3.default.cyan(` \u2192 ${WEB_APP_URL2}/pricing?from=cli_checks`),
|
|
1088
|
-
""
|
|
1089
|
-
],
|
|
1090
1165
|
// When domain limit is reached
|
|
1091
1166
|
domain_limit: (ctx) => [
|
|
1092
1167
|
"",
|
|
@@ -1099,18 +1174,6 @@ var UPSELL_MESSAGES = {
|
|
|
1099
1174
|
import_chalk3.default.red("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"),
|
|
1100
1175
|
""
|
|
1101
1176
|
],
|
|
1102
|
-
// When GEO features are limited
|
|
1103
|
-
geo_limited: (ctx) => [
|
|
1104
|
-
"",
|
|
1105
|
-
import_chalk3.default.yellow("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"),
|
|
1106
|
-
import_chalk3.default.yellow.bold(" \u{1F916} Full GEO analysis requires Solo"),
|
|
1107
|
-
import_chalk3.default.dim(" You're seeing basic AI crawler access checks."),
|
|
1108
|
-
import_chalk3.default.dim(" Full analysis includes: LLM signals, citation readiness, content structure"),
|
|
1109
|
-
"",
|
|
1110
|
-
import_chalk3.default.cyan(` \u2192 ${WEB_APP_URL2}/pricing?from=cli_geo`),
|
|
1111
|
-
import_chalk3.default.yellow("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"),
|
|
1112
|
-
""
|
|
1113
|
-
],
|
|
1114
1177
|
// When auto-fix is attempted without access
|
|
1115
1178
|
auto_fix_blocked: () => [
|
|
1116
1179
|
"",
|
|
@@ -1211,10 +1274,6 @@ function showUpsell(messageKey, context) {
|
|
|
1211
1274
|
const lines = messageFn(ctx);
|
|
1212
1275
|
lines.forEach((line) => console.log(line));
|
|
1213
1276
|
}
|
|
1214
|
-
function hasFeature(tier, feature) {
|
|
1215
|
-
const limits = TIER_LIMITS[tier];
|
|
1216
|
-
return limits?.features?.[feature] ?? false;
|
|
1217
|
-
}
|
|
1218
1277
|
|
|
1219
1278
|
// src/wasm-engine.ts
|
|
1220
1279
|
var vm = __toESM(require("vm"));
|
|
@@ -1385,100 +1444,403 @@ async function getPremiumChecks() {
|
|
|
1385
1444
|
// src/models.ts
|
|
1386
1445
|
var OPENAI_FAST = "gpt-5.6-luna";
|
|
1387
1446
|
var ANTHROPIC_FAST = "claude-haiku-4-5-20251001";
|
|
1447
|
+
var OPENROUTER_DEFAULT = "openrouter/free";
|
|
1448
|
+
var GROQ_DEFAULT = "llama-3.3-70b-versatile";
|
|
1449
|
+
var GEMINI_DEFAULT = "gemini-3.7-flash";
|
|
1450
|
+
var DEEPSEEK_DEFAULT = "deepseek-flash";
|
|
1451
|
+
var MISTRAL_DEFAULT = "mistral-small-latest";
|
|
1452
|
+
var TOGETHER_DEFAULT = "openai/gpt-oss-120b";
|
|
1453
|
+
var FIREWORKS_DEFAULT = "accounts/fireworks/models/gpt-oss-120b";
|
|
1454
|
+
var CEREBRAS_DEFAULT = "gpt-oss-120b";
|
|
1388
1455
|
|
|
1389
|
-
// src/
|
|
1390
|
-
var
|
|
1391
|
-
|
|
1456
|
+
// src/lib/ai.ts
|
|
1457
|
+
var AI_PROVIDERS = {
|
|
1458
|
+
// Hosted
|
|
1459
|
+
openrouter: { label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", model: OPENROUTER_DEFAULT, envKeys: ["OPENROUTER_API_KEY"] },
|
|
1460
|
+
openai: { label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: OPENAI_FAST, envKeys: ["OPENAI_API_KEY"], jsonMode: true },
|
|
1461
|
+
anthropic: { label: "Anthropic", baseUrl: "https://api.anthropic.com/v1", model: ANTHROPIC_FAST, envKeys: ["ANTHROPIC_API_KEY"], anthropic: true },
|
|
1462
|
+
groq: { label: "Groq", baseUrl: "https://api.groq.com/openai/v1", model: GROQ_DEFAULT, envKeys: ["GROQ_API_KEY"], jsonMode: true },
|
|
1463
|
+
gemini: { label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: GEMINI_DEFAULT, envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"] },
|
|
1464
|
+
deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com", model: DEEPSEEK_DEFAULT, envKeys: ["DEEPSEEK_API_KEY"], jsonMode: true },
|
|
1465
|
+
mistral: { label: "Mistral", baseUrl: "https://api.mistral.ai/v1", model: MISTRAL_DEFAULT, envKeys: ["MISTRAL_API_KEY"], jsonMode: true },
|
|
1466
|
+
cerebras: { label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", model: CEREBRAS_DEFAULT, envKeys: ["CEREBRAS_API_KEY"] },
|
|
1467
|
+
together: { label: "Together", baseUrl: "https://api.together.xyz/v1", model: TOGETHER_DEFAULT, envKeys: ["TOGETHER_API_KEY"] },
|
|
1468
|
+
fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", model: FIREWORKS_DEFAULT, envKeys: ["FIREWORKS_API_KEY"] },
|
|
1469
|
+
// Local / self-hosted
|
|
1470
|
+
ollama: { label: "Ollama", baseUrl: "http://localhost:11434/v1", envKeys: [], local: true },
|
|
1471
|
+
lmstudio: { label: "LM Studio", baseUrl: "http://localhost:1234/v1", envKeys: [], local: true },
|
|
1472
|
+
llamacpp: { label: "llama.cpp", baseUrl: "http://localhost:8080/v1", envKeys: [], local: true },
|
|
1473
|
+
vllm: { label: "vLLM", baseUrl: "http://localhost:8000/v1", envKeys: [], local: true },
|
|
1474
|
+
custom: { label: "OpenAI-compatible server", envKeys: [], local: true }
|
|
1475
|
+
};
|
|
1476
|
+
var AI_PROVIDER_IDS = Object.keys(AI_PROVIDERS);
|
|
1477
|
+
var DETECT_ORDER = ["openrouter", "groq", "gemini", "deepseek", "cerebras", "mistral", "together", "fireworks", "anthropic", "openai"];
|
|
1478
|
+
var AI_ENV_KEYS = DETECT_ORDER.flatMap((id) => AI_PROVIDERS[id].envKeys);
|
|
1479
|
+
function providerFromKey(key) {
|
|
1480
|
+
if (key.startsWith("sk-or-")) return "openrouter";
|
|
1481
|
+
if (key.startsWith("sk-ant-")) return "anthropic";
|
|
1482
|
+
if (key.startsWith("gsk_")) return "groq";
|
|
1483
|
+
if (key.startsWith("AIza")) return "gemini";
|
|
1484
|
+
if (key.startsWith("csk-")) return "cerebras";
|
|
1485
|
+
return void 0;
|
|
1486
|
+
}
|
|
1487
|
+
function ollamaBaseUrl(env) {
|
|
1488
|
+
const raw = env.OLLAMA_HOST?.trim();
|
|
1489
|
+
if (!raw) return AI_PROVIDERS.ollama.baseUrl;
|
|
1392
1490
|
try {
|
|
1393
|
-
const
|
|
1394
|
-
const
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
return await
|
|
1491
|
+
const hasScheme = /^https?:\/\//i.test(raw);
|
|
1492
|
+
const u = new URL(hasScheme ? raw : `http://${raw}`);
|
|
1493
|
+
if (!hasScheme && !/:\d+$/.test(raw)) u.port = "11434";
|
|
1494
|
+
if (u.hostname === "0.0.0.0") u.hostname = "127.0.0.1";
|
|
1495
|
+
return `${u.origin}/v1`;
|
|
1496
|
+
} catch {
|
|
1497
|
+
return AI_PROVIDERS.ollama.baseUrl;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
async function fetchWithTimeout(url, init2, timeoutMs) {
|
|
1501
|
+
const controller = new AbortController();
|
|
1502
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1503
|
+
try {
|
|
1504
|
+
return await fetch(url, { ...init2, signal: controller.signal });
|
|
1505
|
+
} finally {
|
|
1506
|
+
clearTimeout(timer);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
async function probeOllama(baseUrl, timeoutMs = 800) {
|
|
1510
|
+
try {
|
|
1511
|
+
const origin = new URL(baseUrl).origin;
|
|
1512
|
+
const res = await fetchWithTimeout(`${origin}/api/tags`, {}, timeoutMs);
|
|
1513
|
+
if (!res.ok) return null;
|
|
1514
|
+
const data = await res.json();
|
|
1515
|
+
return (data.models || []).map((m) => m.name || m.model || "").filter(Boolean);
|
|
1407
1516
|
} catch {
|
|
1408
1517
|
return null;
|
|
1409
1518
|
}
|
|
1410
1519
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
};
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1520
|
+
async function firstServerModel(baseUrl, apiKey) {
|
|
1521
|
+
try {
|
|
1522
|
+
const res = await fetchWithTimeout(`${baseUrl}/models`, {
|
|
1523
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
|
|
1524
|
+
}, 5e3);
|
|
1525
|
+
if (!res.ok) return {};
|
|
1526
|
+
const data = await res.json();
|
|
1527
|
+
return { model: data.data?.find((m) => m.id)?.id };
|
|
1528
|
+
} catch {
|
|
1529
|
+
return { unreachable: true };
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function noProviderHints() {
|
|
1533
|
+
return [
|
|
1534
|
+
`Set one of ${AI_ENV_KEYS.join(", ")}, or pass --ai-key`,
|
|
1535
|
+
"Or run a local model: start Ollama (https://ollama.com) and `ollama pull llama3.2` - it is picked up automatically",
|
|
1536
|
+
"Or point at any OpenAI-compatible server: --ai-base-url http://localhost:8080/v1 --ai-model <name>"
|
|
1537
|
+
];
|
|
1538
|
+
}
|
|
1539
|
+
async function resolveAIProvider(opts, env = process.env, probe = probeOllama) {
|
|
1540
|
+
let provider = opts.aiProvider?.trim().toLowerCase();
|
|
1541
|
+
let notice;
|
|
1542
|
+
let ollamaModels = null;
|
|
1543
|
+
if (provider && !AI_PROVIDERS[provider]) {
|
|
1544
|
+
return {
|
|
1545
|
+
ok: false,
|
|
1546
|
+
error: `Unknown AI provider "${opts.aiProvider}"`,
|
|
1547
|
+
hints: [`Choose one of: ${AI_PROVIDER_IDS.join(", ")}`]
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
if (!provider) {
|
|
1551
|
+
if (opts.aiBaseUrl) {
|
|
1552
|
+
provider = "custom";
|
|
1553
|
+
} else if (opts.aiKey) {
|
|
1554
|
+
provider = providerFromKey(opts.aiKey) ?? "openai";
|
|
1555
|
+
} else {
|
|
1556
|
+
provider = DETECT_ORDER.find((id) => AI_PROVIDERS[id].envKeys.some((k) => env[k]));
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (!provider) {
|
|
1560
|
+
const base = ollamaBaseUrl(env);
|
|
1561
|
+
ollamaModels = await probe(base);
|
|
1562
|
+
if (ollamaModels === null) {
|
|
1563
|
+
return { ok: false, error: "AI analysis requested but no AI provider is configured", hints: noProviderHints() };
|
|
1564
|
+
}
|
|
1565
|
+
provider = "ollama";
|
|
1566
|
+
notice = `No AI key set - using local Ollama at ${base}`;
|
|
1567
|
+
}
|
|
1568
|
+
const preset = AI_PROVIDERS[provider];
|
|
1569
|
+
const baseUrl = (opts.aiBaseUrl || (provider === "ollama" ? ollamaBaseUrl(env) : preset.baseUrl) || "").replace(/\/+$/, "");
|
|
1570
|
+
if (!baseUrl) {
|
|
1571
|
+
return {
|
|
1572
|
+
ok: false,
|
|
1573
|
+
error: "The custom provider needs --ai-base-url",
|
|
1574
|
+
hints: ["e.g. --ai-base-url http://localhost:8080/v1 --ai-model <name>"]
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
new URL(baseUrl);
|
|
1579
|
+
} catch {
|
|
1580
|
+
return { ok: false, error: `Invalid --ai-base-url "${opts.aiBaseUrl}"`, hints: ["Use a full URL such as http://localhost:11434/v1"] };
|
|
1581
|
+
}
|
|
1582
|
+
const apiKey = opts.aiKey || preset.envKeys.map((k) => env[k]).find(Boolean) || void 0;
|
|
1583
|
+
if (!apiKey && !preset.local) {
|
|
1584
|
+
return {
|
|
1585
|
+
ok: false,
|
|
1586
|
+
error: `No API key for ${preset.label}`,
|
|
1587
|
+
hints: [`Set ${preset.envKeys.join(" or ")}, or pass --ai-key`]
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
let model = opts.aiModel || preset.model;
|
|
1591
|
+
if (!model && provider === "ollama") {
|
|
1592
|
+
ollamaModels ??= await probe(baseUrl);
|
|
1593
|
+
if (ollamaModels === null) {
|
|
1594
|
+
return { ok: false, error: `Could not reach Ollama at ${baseUrl}`, hints: ["Start it with `ollama serve`, or set OLLAMA_HOST"] };
|
|
1595
|
+
}
|
|
1596
|
+
model = ollamaModels.find((name) => !/embed/i.test(name));
|
|
1597
|
+
if (!model) {
|
|
1598
|
+
return { ok: false, error: "Ollama is running but has no chat models installed", hints: ["Install one, e.g. `ollama pull llama3.2`, or pass --ai-model"] };
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (!model) {
|
|
1602
|
+
const found = await firstServerModel(baseUrl, apiKey);
|
|
1603
|
+
if (found.unreachable) {
|
|
1604
|
+
return { ok: false, error: `Could not reach ${preset.label} at ${baseUrl}`, hints: ["Is the server running? Or pass a different --ai-base-url"] };
|
|
1605
|
+
}
|
|
1606
|
+
model = found.model;
|
|
1607
|
+
if (!model) {
|
|
1608
|
+
return { ok: false, error: `${preset.label} at ${baseUrl} did not report a loaded model`, hints: ["Pass --ai-model <name>"] };
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (notice) notice += ` (model ${model})`;
|
|
1612
|
+
return {
|
|
1613
|
+
ok: true,
|
|
1614
|
+
ai: {
|
|
1615
|
+
provider,
|
|
1616
|
+
label: preset.label,
|
|
1617
|
+
baseUrl,
|
|
1618
|
+
model,
|
|
1619
|
+
apiKey,
|
|
1620
|
+
// A base-URL override may point anywhere; only trust JSON mode on the real endpoint.
|
|
1621
|
+
jsonMode: !!preset.jsonMode && !opts.aiBaseUrl,
|
|
1622
|
+
anthropic: !!preset.anthropic,
|
|
1623
|
+
local: !!preset.local,
|
|
1624
|
+
notice
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function tryParse(text) {
|
|
1629
|
+
try {
|
|
1630
|
+
return JSON.parse(text);
|
|
1631
|
+
} catch {
|
|
1632
|
+
try {
|
|
1633
|
+
return JSON.parse(text.replace(/,\s*([}\]])/g, "$1"));
|
|
1634
|
+
} catch {
|
|
1635
|
+
return void 0;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
function balancedObjectAt(text, start) {
|
|
1640
|
+
let depth = 0;
|
|
1641
|
+
let inString = false;
|
|
1642
|
+
let escaped = false;
|
|
1643
|
+
for (let i = start; i < text.length; i++) {
|
|
1644
|
+
const ch = text[i];
|
|
1645
|
+
if (inString) {
|
|
1646
|
+
if (escaped) escaped = false;
|
|
1647
|
+
else if (ch === "\\") escaped = true;
|
|
1648
|
+
else if (ch === '"') inString = false;
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
if (ch === '"') inString = true;
|
|
1652
|
+
else if (ch === "{") depth++;
|
|
1653
|
+
else if (ch === "}") {
|
|
1654
|
+
depth--;
|
|
1655
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
function extractJsonObject(content) {
|
|
1661
|
+
const text = content.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
|
1662
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
1663
|
+
const sources = fenced ? [fenced[1], text] : [text];
|
|
1664
|
+
for (const source of sources) {
|
|
1665
|
+
let from = source.indexOf("{");
|
|
1666
|
+
let attempts = 0;
|
|
1667
|
+
while (from !== -1 && attempts++ < 50) {
|
|
1668
|
+
const candidate = balancedObjectAt(source, from);
|
|
1669
|
+
if (candidate) {
|
|
1670
|
+
const parsed = tryParse(candidate);
|
|
1671
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1672
|
+
}
|
|
1673
|
+
from = source.indexOf("{", from + 1);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return null;
|
|
1677
|
+
}
|
|
1678
|
+
function normalizeAnalysis(raw) {
|
|
1679
|
+
const score = Number(raw.contentScore);
|
|
1680
|
+
const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions.map((s) => typeof s === "string" ? s : JSON.stringify(s)).filter(Boolean) : [];
|
|
1681
|
+
const prioritizedFixes = Array.isArray(raw.prioritizedFixes) ? raw.prioritizedFixes.filter((f) => !!f && typeof f === "object" && "code" in f).map((f) => ({
|
|
1682
|
+
code: String(f.code),
|
|
1683
|
+
priority: Number(f.priority) || 3,
|
|
1684
|
+
rationale: String(f.rationale ?? "")
|
|
1685
|
+
})) : [];
|
|
1686
|
+
if (!Number.isFinite(score) && suggestions.length === 0 && prioritizedFixes.length === 0) return null;
|
|
1687
|
+
return {
|
|
1688
|
+
contentScore: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : 0,
|
|
1689
|
+
suggestions,
|
|
1690
|
+
prioritizedFixes
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
var SEVERITY_ORDER = { critical: 0, error: 1, warning: 2, notice: 3, info: 3 };
|
|
1694
|
+
var MAX_ISSUES_IN_PROMPT = 60;
|
|
1695
|
+
function buildPrompt(url, issues) {
|
|
1696
|
+
const ranked = [...issues].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4));
|
|
1697
|
+
const shown = ranked.slice(0, MAX_ISSUES_IN_PROMPT);
|
|
1698
|
+
const more = ranked.length > shown.length ? `
|
|
1699
|
+
(${ranked.length - shown.length} lower-severity issues omitted)` : "";
|
|
1700
|
+
return `Analyze these SEO audit results and provide prioritized recommendations:
|
|
1422
1701
|
|
|
1423
1702
|
URL: ${url}
|
|
1424
1703
|
|
|
1425
1704
|
Issues found:
|
|
1426
|
-
${
|
|
1705
|
+
${shown.map((i) => `- [${i.severity.toUpperCase()}] ${i.code}: ${i.title}`).join("\n")}${more}
|
|
1427
1706
|
|
|
1428
|
-
|
|
1707
|
+
Respond with only this JSON object - no prose, no code fences:
|
|
1429
1708
|
{
|
|
1430
1709
|
"contentScore": <0-100 overall content quality>,
|
|
1431
|
-
"suggestions": [<3-5 high-impact suggestions>],
|
|
1710
|
+
"suggestions": [<3-5 high-impact suggestions, as strings>],
|
|
1432
1711
|
"prioritizedFixes": [
|
|
1433
1712
|
{"code": "<issue code>", "priority": <1-5>, "rationale": "<why fix this first>"}
|
|
1434
1713
|
]
|
|
1435
1714
|
}`;
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1715
|
+
}
|
|
1716
|
+
async function runAIAnalysis(url, issues, ai) {
|
|
1717
|
+
const prompt = buildPrompt(url, issues);
|
|
1718
|
+
const system = "You are an SEO expert. Respond with valid JSON only.";
|
|
1719
|
+
const timeoutMs = ai.local ? 3e5 : 9e4;
|
|
1720
|
+
const maxTokens = 4096;
|
|
1721
|
+
const headers = { "Content-Type": "application/json" };
|
|
1722
|
+
let endpoint;
|
|
1723
|
+
let body;
|
|
1724
|
+
if (ai.anthropic) {
|
|
1725
|
+
endpoint = `${ai.baseUrl}/messages`;
|
|
1726
|
+
headers["x-api-key"] = ai.apiKey || "";
|
|
1727
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
1728
|
+
body = { model: ai.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: prompt }] };
|
|
1729
|
+
} else {
|
|
1730
|
+
endpoint = `${ai.baseUrl}/chat/completions`;
|
|
1731
|
+
if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
|
|
1732
|
+
if (ai.provider === "openrouter") {
|
|
1733
|
+
headers["HTTP-Referer"] = "https://rankcli.dev";
|
|
1734
|
+
headers["X-Title"] = "RankCLI";
|
|
1735
|
+
}
|
|
1736
|
+
body = {
|
|
1737
|
+
model: ai.model,
|
|
1738
|
+
messages: [
|
|
1739
|
+
{ role: "system", content: system },
|
|
1740
|
+
{ role: "user", content: prompt }
|
|
1741
|
+
],
|
|
1742
|
+
// OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
|
|
1743
|
+
...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
|
|
1744
|
+
...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
|
|
1439
1745
|
};
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1746
|
+
}
|
|
1747
|
+
let response;
|
|
1748
|
+
try {
|
|
1749
|
+
response = await fetchWithTimeout(endpoint, { method: "POST", headers, body: JSON.stringify(body) }, timeoutMs);
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
const reason = error instanceof Error && error.name === "AbortError" ? `timed out after ${Math.round(timeoutMs / 1e3)}s` : error instanceof Error ? error.cause instanceof Error ? error.cause.message : error.message : String(error);
|
|
1752
|
+
return { error: `${ai.label} request failed: ${reason}` };
|
|
1753
|
+
}
|
|
1754
|
+
if (!response.ok) {
|
|
1755
|
+
const detail = (await response.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 200);
|
|
1756
|
+
return { error: `${ai.label} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}` };
|
|
1757
|
+
}
|
|
1758
|
+
let data;
|
|
1759
|
+
try {
|
|
1760
|
+
data = await response.json();
|
|
1761
|
+
} catch {
|
|
1762
|
+
return { error: `${ai.label} returned a non-JSON response` };
|
|
1763
|
+
}
|
|
1764
|
+
const content = ai.anthropic ? data.content?.filter((b) => typeof b.text === "string").map((b) => b.text).join("") : data.choices?.[0]?.message?.content;
|
|
1765
|
+
if (!content) {
|
|
1766
|
+
return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
|
|
1767
|
+
}
|
|
1768
|
+
const parsed = extractJsonObject(content);
|
|
1769
|
+
const analysis = parsed ? normalizeAnalysis(parsed) : null;
|
|
1770
|
+
if (!analysis) {
|
|
1771
|
+
return { error: `${ai.label} (${ai.model}) did not return the requested JSON` };
|
|
1772
|
+
}
|
|
1773
|
+
return { analysis };
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// src/commands/audit.ts
|
|
1777
|
+
var { detectFramework: detectFramework2, getFrameworkDisplayName } = import_agent_runtime2.frameworks;
|
|
1778
|
+
var EXIT_CODES = {
|
|
1779
|
+
/** Audit completed and no --fail-on / --min-score gate tripped. */
|
|
1780
|
+
OK: 0,
|
|
1781
|
+
/** A --fail-on or --min-score gate tripped. */
|
|
1782
|
+
GATE_FAILED: 1,
|
|
1783
|
+
/** Bad invocation: missing/invalid --url or an invalid flag value. */
|
|
1784
|
+
USAGE: 2,
|
|
1785
|
+
/** No real audit: the site was unreachable, the URL answered 4xx/5xx, no page could be analyzed, or an internal error. */
|
|
1786
|
+
AUDIT_FAILED: 3
|
|
1787
|
+
};
|
|
1788
|
+
var FAIL_ON_LEVELS = ["error", "warning", "notice"];
|
|
1789
|
+
var SEVERITY_RANK = { critical: 4, error: 3, warning: 2, notice: 1, info: 1 };
|
|
1790
|
+
function evaluateGate(issues, score, failOn, minScore) {
|
|
1791
|
+
const failures = [];
|
|
1792
|
+
if (failOn) {
|
|
1793
|
+
const threshold = SEVERITY_RANK[failOn];
|
|
1794
|
+
const hits = issues.filter((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
|
|
1795
|
+
if (hits.length > 0) {
|
|
1796
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1797
|
+
for (const i of hits) counts.set(i.severity, (counts.get(i.severity) || 0) + 1);
|
|
1798
|
+
const breakdown = [...counts.entries()].sort((a, b) => (SEVERITY_RANK[b[0]] ?? 0) - (SEVERITY_RANK[a[0]] ?? 0)).map(([severity, n]) => `${n} ${severity}`).join(", ");
|
|
1799
|
+
failures.push(`${hits.length} issue${hits.length === 1 ? "" : "s"} at or above --fail-on ${failOn} (${breakdown})`);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
if (minScore !== void 0 && score < minScore) {
|
|
1803
|
+
failures.push(`score ${score} is below --min-score ${minScore}`);
|
|
1804
|
+
}
|
|
1805
|
+
return { failOn, minScore, passed: failures.length === 0, failures };
|
|
1806
|
+
}
|
|
1807
|
+
function parseGateOptions(options) {
|
|
1808
|
+
let failOn;
|
|
1809
|
+
let minScore;
|
|
1810
|
+
if (options.failOn !== void 0) {
|
|
1811
|
+
const level = String(options.failOn).trim().toLowerCase();
|
|
1812
|
+
if (!FAIL_ON_LEVELS.includes(level)) {
|
|
1813
|
+
return { error: `Invalid --fail-on "${options.failOn}" (use ${FAIL_ON_LEVELS.join(", ")})` };
|
|
1814
|
+
}
|
|
1815
|
+
failOn = level;
|
|
1816
|
+
}
|
|
1817
|
+
if (options.minScore !== void 0) {
|
|
1818
|
+
const raw = String(options.minScore).trim();
|
|
1819
|
+
const n = Number(raw);
|
|
1820
|
+
if (raw === "" || !Number.isFinite(n) || n < 0 || n > 100) {
|
|
1821
|
+
return { error: `Invalid --min-score "${options.minScore}" (use a number from 0 to 100)` };
|
|
1468
1822
|
}
|
|
1469
|
-
|
|
1823
|
+
minScore = n;
|
|
1824
|
+
}
|
|
1825
|
+
return { failOn, minScore };
|
|
1826
|
+
}
|
|
1827
|
+
async function fetchHtml(url, timeout = 1e4) {
|
|
1828
|
+
try {
|
|
1829
|
+
const controller = new AbortController();
|
|
1830
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1831
|
+
const response = await fetch(url, {
|
|
1832
|
+
signal: controller.signal,
|
|
1833
|
+
headers: {
|
|
1834
|
+
"User-Agent": "RankCLI-Bot/1.0 (+https://rankcli.dev/bot)",
|
|
1835
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
clearTimeout(timeoutId);
|
|
1470
1839
|
if (!response.ok) {
|
|
1471
|
-
console.error(import_chalk4.default.yellow(`AI analysis failed: ${response.status}`));
|
|
1472
1840
|
return null;
|
|
1473
1841
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
if (!content) return null;
|
|
1477
|
-
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
1478
|
-
if (!jsonMatch) return null;
|
|
1479
|
-
return JSON.parse(jsonMatch[0]);
|
|
1480
|
-
} catch (error) {
|
|
1481
|
-
console.error(import_chalk4.default.yellow(`AI analysis error: ${error}`));
|
|
1842
|
+
return await response.text();
|
|
1843
|
+
} catch {
|
|
1482
1844
|
return null;
|
|
1483
1845
|
}
|
|
1484
1846
|
}
|
|
@@ -1490,6 +1852,9 @@ function mergeIssues(issueArrays) {
|
|
|
1490
1852
|
if (existing) {
|
|
1491
1853
|
const urls = /* @__PURE__ */ new Set([...existing.affectedUrls || [], ...issue.affectedUrls || []]);
|
|
1492
1854
|
existing.affectedUrls = Array.from(urls);
|
|
1855
|
+
if ((SEVERITY_RANK[issue.severity] ?? 0) > (SEVERITY_RANK[existing.severity] ?? 0)) {
|
|
1856
|
+
existing.severity = issue.severity;
|
|
1857
|
+
}
|
|
1493
1858
|
} else {
|
|
1494
1859
|
issueMap.set(issue.code, { ...issue });
|
|
1495
1860
|
}
|
|
@@ -1535,9 +1900,9 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
|
|
|
1535
1900
|
const data = await res.json();
|
|
1536
1901
|
if (!res.ok) {
|
|
1537
1902
|
if (data.error === "project_limit_reached") {
|
|
1538
|
-
console.
|
|
1903
|
+
console.error(import_chalk4.default.yellow(`
|
|
1539
1904
|
\u26A0\uFE0F Project limit reached (${data.count}/${data.limit})`));
|
|
1540
|
-
console.
|
|
1905
|
+
console.error(import_chalk4.default.dim(` Upgrade at: ${WEB_APP_URL}/account`));
|
|
1541
1906
|
} else {
|
|
1542
1907
|
console.error(import_chalk4.default.dim(data.error || "Cloud sync failed"));
|
|
1543
1908
|
}
|
|
@@ -1553,60 +1918,70 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
|
|
|
1553
1918
|
return null;
|
|
1554
1919
|
}
|
|
1555
1920
|
}
|
|
1921
|
+
function printUsage(loggedIn) {
|
|
1922
|
+
console.error("\n\u26A0\uFE0F Please provide a URL to audit:");
|
|
1923
|
+
console.error(" rankcli audit --url https://your-site.com");
|
|
1924
|
+
console.error('\nRun "rankcli audit --help" for every option, exit codes, and AI providers.');
|
|
1925
|
+
if (!loggedIn) {
|
|
1926
|
+
console.error(import_chalk4.default.dim("\nTip: Log in to sync results to the web dashboard:"));
|
|
1927
|
+
console.error(import_chalk4.default.dim(" rankcli login"));
|
|
1928
|
+
}
|
|
1929
|
+
console.error("");
|
|
1930
|
+
}
|
|
1556
1931
|
async function audit(options) {
|
|
1557
1932
|
const url = options.url;
|
|
1558
|
-
const
|
|
1559
|
-
const
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
groq: process.env.GROQ_API_KEY,
|
|
1567
|
-
gemini: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
|
1568
|
-
};
|
|
1569
|
-
const aiKey = options.aiKey || providerEnvKey[aiProvider] || "";
|
|
1570
|
-
const loggedIn = isLoggedIn();
|
|
1571
|
-
const { planId } = getUserInfo();
|
|
1572
|
-
const tierId = planId && planId in TIER_LIMITS ? planId : loggedIn ? "free" : "free_anon";
|
|
1573
|
-
const checksLimit = TIER_LIMITS[tierId].checks;
|
|
1574
|
-
let premiumChecks;
|
|
1575
|
-
if (checksLimit > TIER_LIMITS.free.checks) {
|
|
1576
|
-
const passphrase = getDecryptPassphrase();
|
|
1577
|
-
if (passphrase) {
|
|
1578
|
-
try {
|
|
1579
|
-
await initEngine(passphrase);
|
|
1580
|
-
premiumChecks = await getPremiumChecks();
|
|
1581
|
-
} catch (error) {
|
|
1582
|
-
console.error(import_chalk4.default.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
|
|
1583
|
-
}
|
|
1933
|
+
const json = options.output === "json";
|
|
1934
|
+
const fail = (code, message, extra = {}) => {
|
|
1935
|
+
if (json) {
|
|
1936
|
+
console.log(JSON.stringify({ url: url ?? null, error: message, exitCode: code, ...extra }, null, 2));
|
|
1937
|
+
} else {
|
|
1938
|
+
console.error(`
|
|
1939
|
+
\u274C ${message}
|
|
1940
|
+
`);
|
|
1584
1941
|
}
|
|
1585
|
-
|
|
1942
|
+
return code;
|
|
1943
|
+
};
|
|
1944
|
+
const gate = parseGateOptions(options);
|
|
1945
|
+
if (gate.error) return fail(EXIT_CODES.USAGE, gate.error);
|
|
1586
1946
|
if (!url) {
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
console.log(" --max-pages <n> Max pages to crawl (default: 5)");
|
|
1591
|
-
console.log(" --ai Enable AI analysis");
|
|
1592
|
-
console.log(" --ai-provider <p> AI provider: openai, anthropic, openrouter, groq, or gemini (auto-detected from env if omitted)");
|
|
1593
|
-
console.log(" --ai-key <key> API key (or set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY)");
|
|
1594
|
-
console.log(" --output json Output as JSON");
|
|
1595
|
-
if (!loggedIn) {
|
|
1596
|
-
console.log(import_chalk4.default.dim("\nTip: Log in to sync results to the web dashboard:"));
|
|
1597
|
-
console.log(import_chalk4.default.dim(" rankcli login"));
|
|
1598
|
-
}
|
|
1599
|
-
console.log("");
|
|
1600
|
-
return;
|
|
1947
|
+
if (json) return fail(EXIT_CODES.USAGE, "No URL given - use --url https://your-site.com");
|
|
1948
|
+
printUsage(isLoggedIn());
|
|
1949
|
+
return EXIT_CODES.USAGE;
|
|
1601
1950
|
}
|
|
1602
1951
|
try {
|
|
1603
|
-
new URL(url);
|
|
1952
|
+
const parsed = new URL(url);
|
|
1953
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)");
|
|
1604
1954
|
} catch {
|
|
1605
|
-
|
|
1606
|
-
return;
|
|
1955
|
+
return fail(EXIT_CODES.USAGE, `Invalid URL "${url}" - use a full http(s) URL such as https://example.com`);
|
|
1607
1956
|
}
|
|
1608
|
-
|
|
1957
|
+
if (options.ai && options.aiProvider && !AI_PROVIDERS[options.aiProvider.trim().toLowerCase()]) {
|
|
1958
|
+
return fail(EXIT_CODES.USAGE, `Unknown --ai-provider "${options.aiProvider}" (use ${AI_PROVIDER_IDS.join(", ")})`);
|
|
1959
|
+
}
|
|
1960
|
+
const maxPages = Math.max(1, parseInt(options.maxPages || "5", 10) || 5);
|
|
1609
1961
|
try {
|
|
1962
|
+
const loggedIn = isLoggedIn();
|
|
1963
|
+
const keyPlan = await ensureApiKeyPlan();
|
|
1964
|
+
if (keyPlan?.status === "invalid") {
|
|
1965
|
+
console.error(import_chalk4.default.yellow(`\u26A0\uFE0F RankCLI API key rejected (${keyPlan.error}) - auditing on the Free tier.`));
|
|
1966
|
+
} else if (keyPlan?.status === "unavailable") {
|
|
1967
|
+
console.error(import_chalk4.default.dim(`(could not verify your RankCLI plan: ${keyPlan.error})`));
|
|
1968
|
+
}
|
|
1969
|
+
const { planId } = getUserInfo();
|
|
1970
|
+
const tierId = tierFromPlanId(planId) ?? (loggedIn ? "free" : "free_anon");
|
|
1971
|
+
const checksLimit = TIER_LIMITS[tierId].checks;
|
|
1972
|
+
let premiumChecks;
|
|
1973
|
+
{
|
|
1974
|
+
const passphrase = getDecryptPassphrase();
|
|
1975
|
+
if (passphrase) {
|
|
1976
|
+
try {
|
|
1977
|
+
await initEngine(passphrase);
|
|
1978
|
+
premiumChecks = await getPremiumChecks();
|
|
1979
|
+
} catch (error) {
|
|
1980
|
+
console.error(import_chalk4.default.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
const startTime = Date.now();
|
|
1610
1985
|
const discoverySpinner = (0, import_ora2.default)("Discovering pages...").start();
|
|
1611
1986
|
let pagesToAudit = [url];
|
|
1612
1987
|
const homepageHtml = await fetchHtml(url, 1e4);
|
|
@@ -1626,6 +2001,7 @@ async function audit(options) {
|
|
|
1626
2001
|
try {
|
|
1627
2002
|
const report = await (0, import_agent_runtime2.runFullAudit)({
|
|
1628
2003
|
url: pageUrl,
|
|
2004
|
+
auditRootUrl: url,
|
|
1629
2005
|
maxPages: 1,
|
|
1630
2006
|
checkBrokenLinks: options.checkLinks || false,
|
|
1631
2007
|
// Bundled with --check-links: both mean "verify actual link
|
|
@@ -1662,40 +2038,68 @@ async function audit(options) {
|
|
|
1662
2038
|
}
|
|
1663
2039
|
if (!primaryReport) {
|
|
1664
2040
|
auditSpinner.fail("Audit failed - could not analyze any pages");
|
|
1665
|
-
return;
|
|
2041
|
+
return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed - could not analyze ${url}`);
|
|
2042
|
+
}
|
|
2043
|
+
const pageFailure = primaryReport.issues.find((i) => i.code === "FETCH_ERROR" || i.code === "HTTP_ERROR_STATUS");
|
|
2044
|
+
if (pageFailure) {
|
|
2045
|
+
auditSpinner.fail(`Could not audit ${url}: ${pageFailure.title}`);
|
|
2046
|
+
} else {
|
|
2047
|
+
auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
|
|
1666
2048
|
}
|
|
1667
|
-
auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
|
|
1668
2049
|
const mergedIssues = mergeIssues(allIssues);
|
|
2050
|
+
const score = primaryReport.healthScore?.overall || 0;
|
|
1669
2051
|
let aiAnalysis = null;
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
2052
|
+
let aiError;
|
|
2053
|
+
if (options.ai && !pageFailure) {
|
|
2054
|
+
const resolution = await resolveAIProvider({
|
|
2055
|
+
aiProvider: options.aiProvider,
|
|
2056
|
+
aiKey: options.aiKey,
|
|
2057
|
+
aiModel: options.aiModel,
|
|
2058
|
+
aiBaseUrl: options.aiBaseUrl
|
|
2059
|
+
});
|
|
2060
|
+
if (!resolution.ok) {
|
|
2061
|
+
aiError = resolution.error;
|
|
2062
|
+
console.error(import_chalk4.default.yellow(`
|
|
2063
|
+
\u26A0\uFE0F ${resolution.error}`));
|
|
2064
|
+
for (const hint of resolution.hints) console.error(import_chalk4.default.dim(` ${hint}`));
|
|
1675
2065
|
} else {
|
|
1676
|
-
|
|
2066
|
+
const { ai } = resolution;
|
|
2067
|
+
if (ai.notice) console.error(import_chalk4.default.dim(ai.notice));
|
|
2068
|
+
const aiSpinner = (0, import_ora2.default)(`Running AI analysis (${ai.label}, ${ai.model})...`).start();
|
|
2069
|
+
const result = await runAIAnalysis(url, mergedIssues, ai);
|
|
2070
|
+
if ("analysis" in result) {
|
|
2071
|
+
aiAnalysis = result.analysis;
|
|
2072
|
+
aiSpinner.succeed("AI analysis complete");
|
|
2073
|
+
} else {
|
|
2074
|
+
aiError = result.error;
|
|
2075
|
+
aiSpinner.warn(`AI analysis failed: ${result.error}`);
|
|
2076
|
+
}
|
|
1677
2077
|
}
|
|
1678
|
-
} else if (enableAI && !aiKey) {
|
|
1679
|
-
console.log(import_chalk4.default.yellow("\n\u26A0\uFE0F AI analysis requested but no API key provided"));
|
|
1680
|
-
console.log(import_chalk4.default.dim(" Set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY, or use --ai-key"));
|
|
1681
2078
|
}
|
|
2079
|
+
const gateResult = evaluateGate(mergedIssues, score, gate.failOn, gate.minScore);
|
|
2080
|
+
const gated = gate.failOn !== void 0 || gate.minScore !== void 0;
|
|
2081
|
+
const exitCode = pageFailure ? EXIT_CODES.AUDIT_FAILED : gateResult.passed ? EXIT_CODES.OK : EXIT_CODES.GATE_FAILED;
|
|
1682
2082
|
const totalTime = Date.now() - startTime;
|
|
1683
2083
|
const frameworkName = detectedFramework.framework !== "unknown" ? getFrameworkDisplayName(detectedFramework.framework) : null;
|
|
1684
2084
|
const actualChecksRun = (primaryReport.summary?.passed || 0) + (primaryReport.summary?.errors || 0) + (primaryReport.summary?.warnings || 0) || 200;
|
|
1685
|
-
if (
|
|
2085
|
+
if (json) {
|
|
1686
2086
|
let syncResult = null;
|
|
1687
2087
|
if (loggedIn) {
|
|
1688
2088
|
syncResult = await syncToCloud(url, primaryReport, mergedIssues, successCount, aiAnalysis, totalTime, actualChecksRun);
|
|
1689
2089
|
}
|
|
1690
2090
|
const jsonOutput = {
|
|
1691
2091
|
url,
|
|
1692
|
-
score
|
|
2092
|
+
score,
|
|
1693
2093
|
pagesAudited: successCount,
|
|
1694
2094
|
totalTime,
|
|
1695
2095
|
framework: frameworkName,
|
|
1696
2096
|
issues: mergedIssues,
|
|
1697
2097
|
healthScores: primaryReport.healthScore,
|
|
1698
2098
|
aiAnalysis,
|
|
2099
|
+
...aiError && { aiError },
|
|
2100
|
+
...pageFailure && { error: pageFailure.title },
|
|
2101
|
+
...gated && { gate: gateResult },
|
|
2102
|
+
exitCode,
|
|
1699
2103
|
// Include cloud sync info if synced
|
|
1700
2104
|
...syncResult && {
|
|
1701
2105
|
projectId: syncResult.projectId,
|
|
@@ -1704,11 +2108,13 @@ async function audit(options) {
|
|
|
1704
2108
|
}
|
|
1705
2109
|
};
|
|
1706
2110
|
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
2111
|
+
if (!gateResult.passed) {
|
|
2112
|
+
for (const failure of gateResult.failures) console.error(`Gate failed: ${failure}`);
|
|
2113
|
+
}
|
|
1707
2114
|
} else {
|
|
1708
2115
|
console.log("\n" + import_chalk4.default.bold("\u2550".repeat(60)));
|
|
1709
2116
|
console.log(import_chalk4.default.bold.cyan(` SEO AUDIT REPORT: ${url}`));
|
|
1710
2117
|
console.log(import_chalk4.default.bold("\u2550".repeat(60)));
|
|
1711
|
-
const score = primaryReport.healthScore?.overall || 0;
|
|
1712
2118
|
const scoreColor = score >= 80 ? import_chalk4.default.green : score >= 60 ? import_chalk4.default.yellow : import_chalk4.default.red;
|
|
1713
2119
|
console.log(`
|
|
1714
2120
|
${import_chalk4.default.bold("Score:")} ${scoreColor.bold(score + "/100")}`);
|
|
@@ -1735,9 +2141,9 @@ async function audit(options) {
|
|
|
1735
2141
|
console.log(` Local SEO: ${bar(hs.localSeo || 0)} ${hs.localSeo || 0}%`);
|
|
1736
2142
|
console.log(` AI Readiness: ${bar(hs.aiReadiness || 0)} ${hs.aiReadiness || 0}%`);
|
|
1737
2143
|
}
|
|
1738
|
-
const errors = mergedIssues.filter((i) => i.severity === "error");
|
|
2144
|
+
const errors = mergedIssues.filter((i) => i.severity === "error" || i.severity === "critical");
|
|
1739
2145
|
const warnings = mergedIssues.filter((i) => i.severity === "warning");
|
|
1740
|
-
const notices = mergedIssues.filter((i) => i.severity === "notice");
|
|
2146
|
+
const notices = mergedIssues.filter((i) => i.severity === "notice" || i.severity === "info");
|
|
1741
2147
|
console.log("\n" + import_chalk4.default.bold(" Issues by Severity:"));
|
|
1742
2148
|
console.log(` ${import_chalk4.default.red("\u25CF")} Errors: ${errors.length}`);
|
|
1743
2149
|
console.log(` ${import_chalk4.default.yellow("\u25CF")} Warnings: ${warnings.length}`);
|
|
@@ -1745,7 +2151,7 @@ async function audit(options) {
|
|
|
1745
2151
|
console.log("\n" + import_chalk4.default.bold(" Top Issues:"));
|
|
1746
2152
|
const topIssues = [...errors, ...warnings, ...notices].slice(0, 10);
|
|
1747
2153
|
topIssues.forEach((issue, i) => {
|
|
1748
|
-
const icon = issue.severity === "error" ? import_chalk4.default.red("\u2717") : issue.severity === "warning" ? import_chalk4.default.yellow("!") : import_chalk4.default.blue("i");
|
|
2154
|
+
const icon = issue.severity === "error" || issue.severity === "critical" ? import_chalk4.default.red("\u2717") : issue.severity === "warning" ? import_chalk4.default.yellow("!") : import_chalk4.default.blue("i");
|
|
1749
2155
|
console.log(` ${i + 1}. ${icon} ${issue.title}`);
|
|
1750
2156
|
if (issue.affectedUrls && issue.affectedUrls.length > 1) {
|
|
1751
2157
|
console.log(import_chalk4.default.dim(` Affects ${issue.affectedUrls.length} pages`));
|
|
@@ -1766,21 +2172,15 @@ async function audit(options) {
|
|
|
1766
2172
|
});
|
|
1767
2173
|
}
|
|
1768
2174
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
showUpsell("checks_limit", {
|
|
1777
|
-
tier: loggedIn ? "free" : "free_anon",
|
|
1778
|
-
current: checksRun
|
|
1779
|
-
});
|
|
1780
|
-
}
|
|
1781
|
-
if (options.geo && isFreeTier) {
|
|
1782
|
-
showUpsell("geo_limited", { tier: loggedIn ? "free" : "free_anon" });
|
|
2175
|
+
if (gated) {
|
|
2176
|
+
console.log("");
|
|
2177
|
+
if (gateResult.passed) {
|
|
2178
|
+
console.log(import_chalk4.default.green(` \u2713 CI gate passed${gate.failOn ? ` (--fail-on ${gate.failOn})` : ""}${gate.minScore !== void 0 ? ` (--min-score ${gate.minScore})` : ""}`));
|
|
2179
|
+
} else {
|
|
2180
|
+
for (const failure of gateResult.failures) console.log(import_chalk4.default.red(` \u2717 CI gate failed: ${failure}`));
|
|
2181
|
+
}
|
|
1783
2182
|
}
|
|
2183
|
+
console.log("\n" + import_chalk4.default.bold("\u2550".repeat(60)));
|
|
1784
2184
|
if (loggedIn) {
|
|
1785
2185
|
const syncSpinner = (0, import_ora2.default)("Syncing to cloud...").start();
|
|
1786
2186
|
const syncResult = await syncToCloud(url, primaryReport, mergedIssues, successCount, aiAnalysis, totalTime, actualChecksRun);
|
|
@@ -1796,13 +2196,46 @@ async function audit(options) {
|
|
|
1796
2196
|
} else {
|
|
1797
2197
|
showUpsell("free_audit_complete");
|
|
1798
2198
|
}
|
|
2199
|
+
if (pageFailure) {
|
|
2200
|
+
console.error(import_chalk4.default.red(`
|
|
2201
|
+
\u274C ${pageFailure.title}: ${pageFailure.description}
|
|
2202
|
+
`));
|
|
2203
|
+
}
|
|
1799
2204
|
}
|
|
2205
|
+
return exitCode;
|
|
1800
2206
|
} catch (error) {
|
|
1801
|
-
|
|
1802
|
-
\u274C Audit failed: ${error instanceof Error ? error.message : "Unknown error"}
|
|
1803
|
-
`);
|
|
2207
|
+
return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1804
2208
|
}
|
|
1805
2209
|
}
|
|
2210
|
+
var AUDIT_HELP_TEXT = `
|
|
2211
|
+
Exit codes:
|
|
2212
|
+
0 audit completed; no --fail-on / --min-score gate tripped
|
|
2213
|
+
1 a --fail-on or --min-score gate tripped
|
|
2214
|
+
2 bad invocation (missing/invalid --url, invalid flag value)
|
|
2215
|
+
3 no real audit: site unreachable, the URL answered 4xx/5xx, no page
|
|
2216
|
+
could be analyzed, or an internal error
|
|
2217
|
+
|
|
2218
|
+
CI gating (both opt-in; without them any completed audit exits 0):
|
|
2219
|
+
--fail-on error fail on any error
|
|
2220
|
+
--fail-on warning fail on any warning or error
|
|
2221
|
+
--fail-on notice fail on any issue at all
|
|
2222
|
+
--min-score 80 fail if the overall score is below 80
|
|
2223
|
+
|
|
2224
|
+
AI analysis (--ai) runs on your own provider. Hosted: ${Object.entries(AI_PROVIDERS).filter(([, p]) => !p.local).map(([id]) => id).join(", ")}.
|
|
2225
|
+
Picked automatically from the first key found in:
|
|
2226
|
+
${AI_ENV_KEYS.join(", ")}
|
|
2227
|
+
(OpenRouter first). No key at all? A running local Ollama is used.
|
|
2228
|
+
Local: --ai-provider ollama | lmstudio | llamacpp | vllm (no key needed;
|
|
2229
|
+
the loaded model is used unless you pass --ai-model), or any
|
|
2230
|
+
OpenAI-compatible server: --ai-base-url http://host:port/v1 --ai-model <name>
|
|
2231
|
+
|
|
2232
|
+
Examples:
|
|
2233
|
+
rankcli audit --url https://example.com
|
|
2234
|
+
rankcli audit --url https://example.com --fail-on error --min-score 80
|
|
2235
|
+
rankcli audit --url https://example.com -o json > report.json
|
|
2236
|
+
rankcli audit --url https://example.com --ai --ai-provider deepseek
|
|
2237
|
+
rankcli audit --url https://example.com --ai --ai-provider ollama --ai-model qwen3:8b
|
|
2238
|
+
`;
|
|
1806
2239
|
|
|
1807
2240
|
// src/commands/apply.ts
|
|
1808
2241
|
var import_fs3 = require("fs");
|
|
@@ -1861,14 +2294,6 @@ async function apply(options) {
|
|
|
1861
2294
|
}
|
|
1862
2295
|
console.log("\n\u{1F680} RankCLI - Apply Fixes");
|
|
1863
2296
|
console.log("\u2550".repeat(50));
|
|
1864
|
-
const loggedIn = isLoggedIn();
|
|
1865
|
-
const userInfo = getUserInfo();
|
|
1866
|
-
const tier = userInfo.planName?.toLowerCase() || "free";
|
|
1867
|
-
const hasAutoFix = loggedIn && hasFeature(tier, "auto_fix");
|
|
1868
|
-
if (!hasAutoFix) {
|
|
1869
|
-
showUpsell("auto_fix_blocked");
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
2297
|
console.log("\n\u{1F50D} Detecting framework...");
|
|
1873
2298
|
const frameworkResult = await (0, import_agent_runtime3.detectFramework)({ cwd });
|
|
1874
2299
|
const framework = frameworkResult.success ? frameworkResult.data : { name: "Unknown", metaPattern: "html-head" };
|
|
@@ -2074,6 +2499,29 @@ ${index + 1}. ${icon} ${fix2.issue.message}`);
|
|
|
2074
2499
|
}
|
|
2075
2500
|
}
|
|
2076
2501
|
async function fix(options) {
|
|
2502
|
+
const result = await runFix(options);
|
|
2503
|
+
if (options.output === "json" && !result.json) {
|
|
2504
|
+
result.json = JSON.stringify({
|
|
2505
|
+
url: result.url ?? options.url ?? null,
|
|
2506
|
+
success: result.success,
|
|
2507
|
+
...result.error && { error: result.error },
|
|
2508
|
+
dryRun: !!result.dryRun,
|
|
2509
|
+
fixes: (result.fixes || []).map((f) => ({
|
|
2510
|
+
code: f.issue.code,
|
|
2511
|
+
message: f.issue.message,
|
|
2512
|
+
file: f.file,
|
|
2513
|
+
applied: !!f.applied,
|
|
2514
|
+
skipped: f.skipped,
|
|
2515
|
+
skipReason: f.skipReason
|
|
2516
|
+
})),
|
|
2517
|
+
appliedCount: result.appliedCount ?? 0,
|
|
2518
|
+
skippedCount: result.skippedCount ?? 0,
|
|
2519
|
+
...result.durationMs !== void 0 && { durationMs: result.durationMs }
|
|
2520
|
+
}, null, 2);
|
|
2521
|
+
}
|
|
2522
|
+
return result;
|
|
2523
|
+
}
|
|
2524
|
+
async function runFix(options) {
|
|
2077
2525
|
const startTime = Date.now();
|
|
2078
2526
|
const cwd = options.cwd || process.cwd();
|
|
2079
2527
|
const url = options.url;
|
|
@@ -2098,18 +2546,9 @@ async function fix(options) {
|
|
|
2098
2546
|
return { success: false, error };
|
|
2099
2547
|
}
|
|
2100
2548
|
const loggedIn = isLoggedIn();
|
|
2549
|
+
if (loggedIn) await ensureApiKeyPlan();
|
|
2101
2550
|
const userInfo = getUserInfo();
|
|
2102
|
-
const tier = userInfo.planName
|
|
2103
|
-
const hasAutoFix = loggedIn && hasFeature(tier, "auto_fix");
|
|
2104
|
-
if (!options.dryRun && !hasAutoFix) {
|
|
2105
|
-
if (options.output !== "json") {
|
|
2106
|
-
showUpsell("auto_fix_blocked");
|
|
2107
|
-
}
|
|
2108
|
-
return {
|
|
2109
|
-
success: false,
|
|
2110
|
-
error: "Auto-fix requires Solo tier or higher. Please upgrade at " + WEB_APP_URL + "/account"
|
|
2111
|
-
};
|
|
2112
|
-
}
|
|
2551
|
+
const tier = tierFromPlanId(userInfo.planId) ?? tierFromPlanId(userInfo.planName) ?? "free";
|
|
2113
2552
|
if (options.output !== "json") {
|
|
2114
2553
|
console.log("\n\u{1F527} RankCLI Fix - Agentic SEO Fixer");
|
|
2115
2554
|
console.log("\u2550".repeat(50));
|
|
@@ -3428,7 +3867,7 @@ async function alerts(options) {
|
|
|
3428
3867
|
if (options.list) {
|
|
3429
3868
|
console.log("\u{1F4CB} Current alert configurations:\n");
|
|
3430
3869
|
console.log(" (Alert configs are stored in your RankCLI account)");
|
|
3431
|
-
console.log(" Run `rankcli
|
|
3870
|
+
console.log(" Run `rankcli login` first, then manage alerts at https://rankcli.dev/account?tab=notifications\n");
|
|
3432
3871
|
rl.close();
|
|
3433
3872
|
return;
|
|
3434
3873
|
}
|
|
@@ -3503,8 +3942,8 @@ async function alerts(options) {
|
|
|
3503
3942
|
}
|
|
3504
3943
|
console.log("");
|
|
3505
3944
|
console.log("To save this configuration:");
|
|
3506
|
-
console.log("1. Log in with `rankcli
|
|
3507
|
-
console.log("2. Add alert config at https://rankcli.dev/account
|
|
3945
|
+
console.log("1. Log in with `rankcli login`");
|
|
3946
|
+
console.log("2. Add alert config at https://rankcli.dev/account?tab=notifications");
|
|
3508
3947
|
console.log("");
|
|
3509
3948
|
console.log("Or use the API:");
|
|
3510
3949
|
console.log(` POST /api/alerts with webhook_url and settings`);
|
|
@@ -3563,17 +4002,21 @@ async function testWebhook(channel, webhookUrl) {
|
|
|
3563
4002
|
}
|
|
3564
4003
|
|
|
3565
4004
|
// src/version.ts
|
|
3566
|
-
var VERSION = "0.0.
|
|
4005
|
+
var VERSION = "0.0.31";
|
|
3567
4006
|
|
|
3568
4007
|
// src/index.ts
|
|
3569
4008
|
var program = new import_commander.Command();
|
|
3570
4009
|
program.name("rankcli").description("RankCLI - Ship code, get ranked. SEO meets CI/CD.").version(VERSION);
|
|
3571
|
-
program.command("login").description("Log in to your RankCLI account (opens your browser by default)").option("-e, --email <email>", "Log in with email/password instead of the browser").option("--no-browser", "Skip the browser and log in with email/password interactively").option("-t, --token <apiKey>", "API key for non-interactive authentication").action(
|
|
4010
|
+
program.command("login").description("Log in to your RankCLI account (opens your browser by default)").option("-e, --email <email>", "Log in with email/password instead of the browser").option("--no-browser", "Skip the browser and log in with email/password interactively").option("-t, --token <apiKey>", "API key for non-interactive authentication (validates it and resolves your plan)").action(async (options) => {
|
|
4011
|
+
process.exitCode = await login(options);
|
|
4012
|
+
});
|
|
3572
4013
|
program.command("logout").description("Log out of your RankCLI account").action(logout);
|
|
3573
4014
|
program.command("whoami").description("Show current logged in user").action(whoami);
|
|
3574
4015
|
program.command("init").description("Set up RankCLI in your project (detects framework, git/GitHub, AI keys)").option("-y, --yes", "Skip prompts and use defaults").action(init);
|
|
3575
4016
|
program.command("analyze").description("AI analyzes your codebase for SEO opportunities").option("-v, --verbose", "Show detailed output").action(analyze);
|
|
3576
|
-
program.command("audit").description("Run a comprehensive SEO audit (200+ checks, Ahrefs-level)").option("-u, --url <url>", "URL to audit").option("-o, --output <format>", "Output format (json, console)", "console").option("--check-links", "Check for broken internal/external links (slower)").option("--max-pages <n>", "Max pages to crawl (default: 5)").option("--ai", "Enable AI analysis").option("--ai-provider <provider>", "AI provider: openai, anthropic,
|
|
4017
|
+
program.command("audit").description("Run a comprehensive SEO audit (200+ checks, Ahrefs-level)").option("-u, --url <url>", "URL to audit").option("-o, --output <format>", "Output format (json, console)", "console").option("--check-links", "Check for broken internal/external links (slower)").option("--max-pages <n>", "Max pages to crawl (default: 5)").option("--fail-on <level>", "CI gate: exit 1 if any issue is at or above this severity (error, warning, notice)").option("--min-score <n>", "CI gate: exit 1 if the overall score is below n (0-100)").option("--ai", "Enable AI analysis (your own key, or a local model)").option("--ai-provider <provider>", "AI provider (auto-detected if omitted): openrouter, openai, anthropic, groq, gemini, deepseek, mistral, cerebras, together, fireworks, ollama, lmstudio, llamacpp, vllm, custom").option("--ai-key <key>", "AI provider API key (or set the provider's env var, e.g. OPENROUTER_API_KEY)").option("--ai-model <model>", "Model id (defaults to a cheap model per provider, or the local server's loaded model)").option("--ai-base-url <url>", "OpenAI-compatible base URL, e.g. http://localhost:8080/v1 (implies --ai-provider custom)").addHelpText("after", AUDIT_HELP_TEXT).action(async (options) => {
|
|
4018
|
+
process.exitCode = await audit(options);
|
|
4019
|
+
});
|
|
3577
4020
|
program.command("apply").description("Analyze a live URL and apply SEO fixes to your codebase").option("-u, --url <url>", "URL to analyze").option("--auto", "Auto-apply fixes without confirmation").option("--dry-run", "Preview changes without applying").action(apply);
|
|
3578
4021
|
program.command("fix").description("Agentic SEO fixer - detect, fix, commit, and push").option("-u, --url <url>", "URL to analyze").option("--auto", "Auto-apply fixes without confirmation").option("--commit", "Commit changes with conventional commit message").option("--push", "Push changes after committing (requires --commit)").option("--dry-run", "Preview changes without applying").option("--only <codes>", "Fix only specific issue codes (comma-separated)", (val) => val.split(",")).option("-o, --output <format>", "Output format (text, json)", "text").action(async (options) => {
|
|
3579
4022
|
const result = await fix({
|
|
@@ -3654,7 +4097,10 @@ program.command("report").description("Generate SEO performance report").option(
|
|
|
3654
4097
|
program.command("watch").description("Continuous monitoring mode").action(() => {
|
|
3655
4098
|
console.log("Watch mode coming soon...");
|
|
3656
4099
|
});
|
|
3657
|
-
program.command("setup").description("Set up SEO tracking (GA4, Search Console, GitHub Actions)").option("--ga4 <id>", "Google Analytics 4 Measurement ID (G-XXXXXXXXXX)").option("--gsc <code>", "Google Search Console verification code").option("--github-action", "Set up automated GitHub Action").option("--schedule <freq>", "GitHub Action schedule (daily, weekly, monthly)", "weekly").option("--all", "Interactive setup wizard").action(setup);
|
|
4100
|
+
program.command("setup").description("Set up SEO tracking (GA4, Search Console, GitHub Actions)").option("--ga4 <id>", "Google Analytics 4 Measurement ID (G-XXXXXXXXXX)").option("--gsc <code>", "Google Search Console verification code").option("--github-action", "Set up automated GitHub Action").option("--schedule <freq>", "GitHub Action schedule (daily, weekly, monthly)", "weekly").option("--all", "Interactive setup wizard").option("--url <url>", "Site URL the GitHub Action audits").option("--sync", "GitHub Action: sync audits to your dashboard and enable auto-fix PRs (needs a RANKCLI_API_KEY secret)").action(setup);
|
|
3658
4101
|
program.command("gsc-setup").description("Show Google Search Console API setup instructions").action(showGSCInstructions);
|
|
3659
4102
|
program.command("alerts").description("Configure Slack/Discord notifications for SEO events").option("-c, --channel <channel>", "Alert channel (slack, discord)").option("-w, --webhook <url>", "Webhook URL").option("--test", "Test the webhook").option("--list", "List current alert configurations").option("--remove <id>", "Remove an alert configuration").action(alerts);
|
|
3660
|
-
program.
|
|
4103
|
+
program.parseAsync().catch((error) => {
|
|
4104
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
4105
|
+
process.exitCode = 1;
|
|
4106
|
+
});
|