@rankcli/cli 0.0.28 → 0.0.30
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 +81 -16
- package/bin/rankcli.cjs +18 -0
- package/dist/index.js +703 -202
- package/dist/index.mjs +703 -202
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import open from "open";
|
|
|
22
22
|
|
|
23
23
|
// src/lib/config.ts
|
|
24
24
|
import Conf from "conf";
|
|
25
|
+
import { createHash } from "crypto";
|
|
25
26
|
import { createClient } from "@supabase/supabase-js";
|
|
26
27
|
const INJECTED_CONFIG = {"supabaseUrl":"https://bspljbxwbjiqueeyzyat.supabase.co","supabaseAnonKey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJzcGxqYnh3YmppcXVlZXl6eWF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNzQ1NTksImV4cCI6MjA4NDg1MDU1OX0.7gprxM2gT9cZ99sjZURLltrln78dEnwyFFKYpTzBaUU","webAppUrl":"https://rankcli.dev","decryptPassphrase":"lKFLeIw/xGPa6E8c4XswZX/oiLtJf/yLF/Y6zwovte8="};
|
|
27
28
|
function getSupabaseUrl() {
|
|
@@ -61,6 +62,7 @@ function getApiUrl() {
|
|
|
61
62
|
}
|
|
62
63
|
var config = new Conf({
|
|
63
64
|
projectName: "rankcli",
|
|
65
|
+
...process.env.RANKCLI_CONFIG_DIR ? { cwd: process.env.RANKCLI_CONFIG_DIR } : {},
|
|
64
66
|
schema: {
|
|
65
67
|
accessToken: { type: "string" },
|
|
66
68
|
refreshToken: { type: "string" },
|
|
@@ -71,7 +73,9 @@ var config = new Conf({
|
|
|
71
73
|
apiKeyName: { type: "string" },
|
|
72
74
|
planId: { type: "string" },
|
|
73
75
|
planName: { type: "string" },
|
|
74
|
-
sitesLimit: { type: "number" }
|
|
76
|
+
sitesLimit: { type: "number" },
|
|
77
|
+
planKeyFingerprint: { type: "string" },
|
|
78
|
+
planCheckedAt: { type: "number" }
|
|
75
79
|
}
|
|
76
80
|
});
|
|
77
81
|
var supabaseClient = null;
|
|
@@ -122,15 +126,17 @@ async function validateApiKey(apiKey) {
|
|
|
122
126
|
method: "POST",
|
|
123
127
|
headers: {
|
|
124
128
|
"Content-Type": "application/json",
|
|
125
|
-
"X-API-Key": apiKey
|
|
129
|
+
"X-API-Key": apiKey,
|
|
130
|
+
"X-Endpoint": "cli"
|
|
126
131
|
}
|
|
127
132
|
});
|
|
128
|
-
const data = await response.json();
|
|
133
|
+
const data = await response.json().catch(() => ({}));
|
|
129
134
|
if (!response.ok) {
|
|
130
|
-
return { valid: false, error: data.error ||
|
|
135
|
+
return { valid: false, status: response.status, error: data.error || `Invalid API key (HTTP ${response.status})` };
|
|
131
136
|
}
|
|
132
137
|
return {
|
|
133
138
|
valid: true,
|
|
139
|
+
status: response.status,
|
|
134
140
|
user: data.user,
|
|
135
141
|
subscription: data.subscription
|
|
136
142
|
};
|
|
@@ -138,6 +144,50 @@ async function validateApiKey(apiKey) {
|
|
|
138
144
|
return { valid: false, error: err instanceof Error ? err.message : "Validation failed" };
|
|
139
145
|
}
|
|
140
146
|
}
|
|
147
|
+
function keyFingerprint(apiKey) {
|
|
148
|
+
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
|
149
|
+
}
|
|
150
|
+
var PLAN_CACHE_MS = 6 * 60 * 60 * 1e3;
|
|
151
|
+
async function ensureApiKeyPlan(options = {}) {
|
|
152
|
+
if (!isApiKeyAuth()) return null;
|
|
153
|
+
const apiKey = getApiKey();
|
|
154
|
+
const fingerprint = keyFingerprint(apiKey);
|
|
155
|
+
const sameKey = config.get("planKeyFingerprint") === fingerprint;
|
|
156
|
+
const fresh = sameKey && Date.now() - (config.get("planCheckedAt") || 0) < PLAN_CACHE_MS;
|
|
157
|
+
if (fresh && !options.force) {
|
|
158
|
+
return { status: "ok", planId: config.get("planId"), planName: config.get("planName") || "Free", email: config.get("email"), cached: true };
|
|
159
|
+
}
|
|
160
|
+
const result = await validateApiKey(apiKey);
|
|
161
|
+
if (result.valid) {
|
|
162
|
+
config.set("planKeyFingerprint", fingerprint);
|
|
163
|
+
config.set("planCheckedAt", Date.now());
|
|
164
|
+
if (result.user?.email) config.set("email", result.user.email);
|
|
165
|
+
if (result.subscription?.planId && result.subscription.planId !== "free") {
|
|
166
|
+
saveSubscription(result.subscription);
|
|
167
|
+
} else {
|
|
168
|
+
setFreePlan();
|
|
169
|
+
}
|
|
170
|
+
return { status: "ok", planId: config.get("planId"), planName: config.get("planName") || "Free", email: result.user?.email, cached: false };
|
|
171
|
+
}
|
|
172
|
+
if (result.status === 403) {
|
|
173
|
+
config.set("planKeyFingerprint", fingerprint);
|
|
174
|
+
config.set("planCheckedAt", Date.now());
|
|
175
|
+
setFreePlan();
|
|
176
|
+
return { status: "free" };
|
|
177
|
+
}
|
|
178
|
+
if (result.status === 401) {
|
|
179
|
+
config.delete("planKeyFingerprint");
|
|
180
|
+
setFreePlan();
|
|
181
|
+
return { status: "invalid", error: result.error || "Invalid API key" };
|
|
182
|
+
}
|
|
183
|
+
if (!sameKey) setFreePlan();
|
|
184
|
+
return { status: "unavailable", error: result.error || "Validation failed" };
|
|
185
|
+
}
|
|
186
|
+
function setFreePlan() {
|
|
187
|
+
config.delete("planId");
|
|
188
|
+
config.set("planName", "Free");
|
|
189
|
+
config.set("sitesLimit", 1);
|
|
190
|
+
}
|
|
141
191
|
async function getSession() {
|
|
142
192
|
const accessToken = config.get("accessToken");
|
|
143
193
|
const refreshToken = config.get("refreshToken");
|
|
@@ -197,6 +247,8 @@ function clearCredentials() {
|
|
|
197
247
|
config.delete("planId");
|
|
198
248
|
config.delete("planName");
|
|
199
249
|
config.delete("sitesLimit");
|
|
250
|
+
config.delete("planKeyFingerprint");
|
|
251
|
+
config.delete("planCheckedAt");
|
|
200
252
|
}
|
|
201
253
|
function getUserInfo() {
|
|
202
254
|
return {
|
|
@@ -231,7 +283,7 @@ async function browserLogin(spinner) {
|
|
|
231
283
|
} catch (err) {
|
|
232
284
|
spinner.fail("Could not start login");
|
|
233
285
|
console.log(chalk.red(err instanceof Error ? err.message : "Network error"));
|
|
234
|
-
return;
|
|
286
|
+
return false;
|
|
235
287
|
}
|
|
236
288
|
spinner.text = "Opening browser to approve...";
|
|
237
289
|
try {
|
|
@@ -242,6 +294,10 @@ async function browserLogin(spinner) {
|
|
|
242
294
|
If a browser didn't open, visit:
|
|
243
295
|
${start.url}
|
|
244
296
|
`));
|
|
297
|
+
if (start.userCode) {
|
|
298
|
+
console.log(`Approve in your browser only if it shows this code: ${chalk.bold(start.userCode)}
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
245
301
|
const deadline = Date.now() + start.expiresInSeconds * 1e3;
|
|
246
302
|
spinner.text = "Waiting for approval in your browser...";
|
|
247
303
|
while (Date.now() < deadline) {
|
|
@@ -259,58 +315,75 @@ ${start.url}
|
|
|
259
315
|
}
|
|
260
316
|
if (status.status === "claimed" && status.apiKey) {
|
|
261
317
|
saveApiKey(status.apiKey, status.apiKeyName, status.email);
|
|
262
|
-
await
|
|
318
|
+
await ensureApiKeyPlan({ force: true });
|
|
263
319
|
spinner.succeed(`Logged in as ${chalk.green(status.email)}`);
|
|
264
|
-
return;
|
|
320
|
+
return true;
|
|
265
321
|
}
|
|
266
322
|
if (status.status === "expired" || status.status === "not_found") {
|
|
267
323
|
spinner.fail("Login link expired");
|
|
268
324
|
console.log(chalk.dim('Run "rankcli login" again.'));
|
|
269
|
-
return;
|
|
325
|
+
return false;
|
|
270
326
|
}
|
|
271
327
|
}
|
|
272
328
|
spinner.fail("Timed out waiting for approval");
|
|
273
329
|
console.log(chalk.dim(`Run "rankcli login" again when you're ready.`));
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
async function verifyApiKey(spinner, source) {
|
|
333
|
+
spinner.start("Validating API key...");
|
|
334
|
+
const plan = await ensureApiKeyPlan({ force: true });
|
|
335
|
+
if (!plan || plan.status === "invalid") {
|
|
336
|
+
spinner.fail("Invalid API key");
|
|
337
|
+
console.log(chalk.red(`
|
|
338
|
+
${plan?.error || "The key was rejected"}`));
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
if (plan.status === "unavailable") {
|
|
342
|
+
spinner.fail("Could not validate API key");
|
|
343
|
+
console.log(chalk.red(`
|
|
344
|
+
${plan.error}`));
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
spinner.succeed(`Authenticated via ${source}`);
|
|
348
|
+
const { email, planName } = getUserInfo();
|
|
349
|
+
if (email) console.log(chalk.dim(`
|
|
350
|
+
Email: ${email}`));
|
|
351
|
+
console.log(chalk.dim(`${email ? "" : "\n"}Plan: ${planName || "Free"}`));
|
|
352
|
+
if (plan.status === "free") {
|
|
353
|
+
console.log(chalk.dim("The key works for dashboard sync; the paid checks and API features need a paid plan."));
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
274
356
|
}
|
|
275
357
|
async function login(options) {
|
|
358
|
+
const spinner = ora();
|
|
359
|
+
if (options.token !== void 0) {
|
|
360
|
+
const apiKey = options.token.trim();
|
|
361
|
+
if (!apiKey.startsWith("rankcli_")) {
|
|
362
|
+
console.log(chalk.red(apiKey ? 'Invalid API key format. Keys should start with "rankcli_"' : "Empty API key - is the RANKCLI_API_KEY secret set?"));
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
if (process.env.RANKCLI_API_KEY && process.env.RANKCLI_API_KEY !== apiKey) {
|
|
366
|
+
console.log(chalk.red("RANKCLI_API_KEY is set to a different key and would take precedence over --token. Unset it or pass the same key."));
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
clearCredentials();
|
|
370
|
+
saveApiKey(apiKey);
|
|
371
|
+
const code = await verifyApiKey(spinner, "API key");
|
|
372
|
+
if (code !== 0) clearCredentials();
|
|
373
|
+
return code;
|
|
374
|
+
}
|
|
375
|
+
if (process.env.RANKCLI_API_KEY) {
|
|
376
|
+
return verifyApiKey(spinner, "RANKCLI_API_KEY");
|
|
377
|
+
}
|
|
276
378
|
if (isLoggedIn()) {
|
|
277
379
|
const { email } = getUserInfo();
|
|
278
380
|
console.log(chalk.yellow(`Already logged in as ${email}`));
|
|
279
381
|
console.log(chalk.dim('Run "rankcli logout" to sign out first.'));
|
|
280
|
-
return;
|
|
382
|
+
return 0;
|
|
281
383
|
}
|
|
282
|
-
const spinner = ora();
|
|
283
384
|
try {
|
|
284
|
-
if (options.token) {
|
|
285
|
-
const apiKey = options.token;
|
|
286
|
-
if (!apiKey.startsWith("rankcli_")) {
|
|
287
|
-
console.log(chalk.red('Invalid API key format. Keys should start with "rankcli_"'));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
spinner.start("Validating API key...");
|
|
291
|
-
const result = await validateApiKey(apiKey);
|
|
292
|
-
if (!result.valid) {
|
|
293
|
-
spinner.fail("Invalid API key");
|
|
294
|
-
console.log(chalk.red(`
|
|
295
|
-
${result.error}`));
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
saveApiKey(apiKey);
|
|
299
|
-
if (result.subscription) {
|
|
300
|
-
saveSubscription({
|
|
301
|
-
planId: result.subscription.planId,
|
|
302
|
-
planName: result.subscription.planName,
|
|
303
|
-
sitesLimit: result.subscription.sitesLimit
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
spinner.succeed(`Authenticated via API key`);
|
|
307
|
-
console.log(chalk.dim(`
|
|
308
|
-
Email: ${result.user?.email}`));
|
|
309
|
-
console.log(chalk.dim(`Plan: ${result.subscription?.planName || "Free"}`));
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
385
|
if (options.browser !== false && !options.email) {
|
|
313
|
-
await browserLogin(spinner);
|
|
386
|
+
return await browserLogin(spinner) ? 0 : 1;
|
|
314
387
|
} else {
|
|
315
388
|
let email = options.email;
|
|
316
389
|
if (!email) {
|
|
@@ -349,11 +422,11 @@ ${error.message}`));
|
|
|
349
422
|
console.log(chalk.dim("\nDon't have an account? Sign up at:"));
|
|
350
423
|
console.log(chalk.cyan(` ${WEB_APP_URL}/signup`));
|
|
351
424
|
}
|
|
352
|
-
return;
|
|
425
|
+
return 1;
|
|
353
426
|
}
|
|
354
427
|
if (!data.session) {
|
|
355
428
|
spinner.fail("No session returned");
|
|
356
|
-
return;
|
|
429
|
+
return 1;
|
|
357
430
|
}
|
|
358
431
|
saveSession(data.session);
|
|
359
432
|
await fetchAndSaveSubscription();
|
|
@@ -361,10 +434,12 @@ ${error.message}`));
|
|
|
361
434
|
const { planName, sitesLimit } = getUserInfo();
|
|
362
435
|
console.log(chalk.dim(`
|
|
363
436
|
Plan: ${planName} (${sitesLimit} site${sitesLimit !== 1 ? "s" : ""})`));
|
|
437
|
+
return 0;
|
|
364
438
|
}
|
|
365
439
|
} catch (err) {
|
|
366
440
|
spinner.fail("Login failed");
|
|
367
441
|
console.error(chalk.red(err instanceof Error ? err.message : "Unknown error"));
|
|
442
|
+
return 1;
|
|
368
443
|
}
|
|
369
444
|
}
|
|
370
445
|
async function logout() {
|
|
@@ -394,16 +469,10 @@ async function whoami() {
|
|
|
394
469
|
const spinner = ora("Checking account...").start();
|
|
395
470
|
try {
|
|
396
471
|
if (isApiKeyAuth()) {
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
saveSubscription({
|
|
402
|
-
planId: result.subscription.planId,
|
|
403
|
-
planName: result.subscription.planName,
|
|
404
|
-
sitesLimit: result.subscription.sitesLimit
|
|
405
|
-
});
|
|
406
|
-
}
|
|
472
|
+
const plan = await ensureApiKeyPlan({ force: true });
|
|
473
|
+
if (plan?.status === "invalid") {
|
|
474
|
+
spinner.fail(`API key rejected: ${plan.error}`);
|
|
475
|
+
return;
|
|
407
476
|
}
|
|
408
477
|
} else {
|
|
409
478
|
await fetchAndSaveSubscription();
|
|
@@ -481,7 +550,10 @@ async function setup(options) {
|
|
|
481
550
|
await setupGSC(projectPath, options.gsc);
|
|
482
551
|
}
|
|
483
552
|
if (options.githubAction) {
|
|
484
|
-
|
|
553
|
+
if (!options.url) {
|
|
554
|
+
console.log("\u2139\uFE0F No --url given - the workflow audits https://example.com until you edit SITE_URL in it.\n");
|
|
555
|
+
}
|
|
556
|
+
await setupGitHubAction(projectPath, options.schedule || "weekly", options.url, options.sync);
|
|
485
557
|
}
|
|
486
558
|
}
|
|
487
559
|
async function interactiveSetup(projectPath) {
|
|
@@ -614,9 +686,12 @@ async function setupGitHubAction(projectPath, schedule, siteUrl, syncToDashboard
|
|
|
614
686
|
siteUrl: siteUrl || "https://example.com",
|
|
615
687
|
features: {
|
|
616
688
|
audit: true,
|
|
617
|
-
|
|
689
|
+
// `rankcli fix --auto` needs a Solo+ plan, which the workflow can
|
|
690
|
+
// only prove with a RANKCLI_API_KEY secret - without one the step
|
|
691
|
+
// is always tier-blocked, so don't generate it.
|
|
692
|
+
autoFix: !!syncToDashboard,
|
|
618
693
|
createIssues: true,
|
|
619
|
-
createPRs:
|
|
694
|
+
createPRs: !!syncToDashboard
|
|
620
695
|
},
|
|
621
696
|
syncToDashboard
|
|
622
697
|
};
|
|
@@ -1054,6 +1129,11 @@ var TIER_LIMITS = {
|
|
|
1054
1129
|
features: { geo: true, auto_fix: true, github: true, ai: true }
|
|
1055
1130
|
}
|
|
1056
1131
|
};
|
|
1132
|
+
function tierFromPlanId(planId) {
|
|
1133
|
+
if (!planId) return void 0;
|
|
1134
|
+
const base = planId.toLowerCase().replace(/^ltd_/, "").replace(/_(annual|yearly|monthly|founding|lifetime|ltd)$/, "");
|
|
1135
|
+
return base === "free" || base === "solo" || base === "pro" || base === "agency" ? base : void 0;
|
|
1136
|
+
}
|
|
1057
1137
|
var UPSELL_MESSAGES = {
|
|
1058
1138
|
// After free audit completes
|
|
1059
1139
|
free_audit_complete: () => [
|
|
@@ -1203,98 +1283,406 @@ function hasFeature(tier, feature) {
|
|
|
1203
1283
|
return limits?.features?.[feature] ?? false;
|
|
1204
1284
|
}
|
|
1205
1285
|
|
|
1206
|
-
// src/
|
|
1207
|
-
var
|
|
1208
|
-
|
|
1286
|
+
// src/models.ts
|
|
1287
|
+
var OPENAI_FAST = "gpt-5.6-luna";
|
|
1288
|
+
var ANTHROPIC_FAST = "claude-haiku-4-5-20251001";
|
|
1289
|
+
var OPENROUTER_DEFAULT = "openrouter/free";
|
|
1290
|
+
var GROQ_DEFAULT = "llama-3.3-70b-versatile";
|
|
1291
|
+
var GEMINI_DEFAULT = "gemini-3.7-flash";
|
|
1292
|
+
var DEEPSEEK_DEFAULT = "deepseek-flash";
|
|
1293
|
+
var MISTRAL_DEFAULT = "mistral-small-latest";
|
|
1294
|
+
var TOGETHER_DEFAULT = "openai/gpt-oss-120b";
|
|
1295
|
+
var FIREWORKS_DEFAULT = "accounts/fireworks/models/gpt-oss-120b";
|
|
1296
|
+
var CEREBRAS_DEFAULT = "gpt-oss-120b";
|
|
1297
|
+
|
|
1298
|
+
// src/lib/ai.ts
|
|
1299
|
+
var AI_PROVIDERS = {
|
|
1300
|
+
// Hosted
|
|
1301
|
+
openrouter: { label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", model: OPENROUTER_DEFAULT, envKeys: ["OPENROUTER_API_KEY"] },
|
|
1302
|
+
openai: { label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: OPENAI_FAST, envKeys: ["OPENAI_API_KEY"], jsonMode: true },
|
|
1303
|
+
anthropic: { label: "Anthropic", baseUrl: "https://api.anthropic.com/v1", model: ANTHROPIC_FAST, envKeys: ["ANTHROPIC_API_KEY"], anthropic: true },
|
|
1304
|
+
groq: { label: "Groq", baseUrl: "https://api.groq.com/openai/v1", model: GROQ_DEFAULT, envKeys: ["GROQ_API_KEY"], jsonMode: true },
|
|
1305
|
+
gemini: { label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: GEMINI_DEFAULT, envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"] },
|
|
1306
|
+
deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com", model: DEEPSEEK_DEFAULT, envKeys: ["DEEPSEEK_API_KEY"], jsonMode: true },
|
|
1307
|
+
mistral: { label: "Mistral", baseUrl: "https://api.mistral.ai/v1", model: MISTRAL_DEFAULT, envKeys: ["MISTRAL_API_KEY"], jsonMode: true },
|
|
1308
|
+
cerebras: { label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", model: CEREBRAS_DEFAULT, envKeys: ["CEREBRAS_API_KEY"] },
|
|
1309
|
+
together: { label: "Together", baseUrl: "https://api.together.xyz/v1", model: TOGETHER_DEFAULT, envKeys: ["TOGETHER_API_KEY"] },
|
|
1310
|
+
fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", model: FIREWORKS_DEFAULT, envKeys: ["FIREWORKS_API_KEY"] },
|
|
1311
|
+
// Local / self-hosted
|
|
1312
|
+
ollama: { label: "Ollama", baseUrl: "http://localhost:11434/v1", envKeys: [], local: true },
|
|
1313
|
+
lmstudio: { label: "LM Studio", baseUrl: "http://localhost:1234/v1", envKeys: [], local: true },
|
|
1314
|
+
llamacpp: { label: "llama.cpp", baseUrl: "http://localhost:8080/v1", envKeys: [], local: true },
|
|
1315
|
+
vllm: { label: "vLLM", baseUrl: "http://localhost:8000/v1", envKeys: [], local: true },
|
|
1316
|
+
custom: { label: "OpenAI-compatible server", envKeys: [], local: true }
|
|
1317
|
+
};
|
|
1318
|
+
var AI_PROVIDER_IDS = Object.keys(AI_PROVIDERS);
|
|
1319
|
+
var DETECT_ORDER = ["openrouter", "groq", "gemini", "deepseek", "cerebras", "mistral", "together", "fireworks", "anthropic", "openai"];
|
|
1320
|
+
var AI_ENV_KEYS = DETECT_ORDER.flatMap((id) => AI_PROVIDERS[id].envKeys);
|
|
1321
|
+
function providerFromKey(key) {
|
|
1322
|
+
if (key.startsWith("sk-or-")) return "openrouter";
|
|
1323
|
+
if (key.startsWith("sk-ant-")) return "anthropic";
|
|
1324
|
+
if (key.startsWith("gsk_")) return "groq";
|
|
1325
|
+
if (key.startsWith("AIza")) return "gemini";
|
|
1326
|
+
if (key.startsWith("csk-")) return "cerebras";
|
|
1327
|
+
return void 0;
|
|
1328
|
+
}
|
|
1329
|
+
function ollamaBaseUrl(env) {
|
|
1330
|
+
const raw = env.OLLAMA_HOST?.trim();
|
|
1331
|
+
if (!raw) return AI_PROVIDERS.ollama.baseUrl;
|
|
1209
1332
|
try {
|
|
1210
|
-
const
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
return await
|
|
1333
|
+
const hasScheme = /^https?:\/\//i.test(raw);
|
|
1334
|
+
const u = new URL(hasScheme ? raw : `http://${raw}`);
|
|
1335
|
+
if (!hasScheme && !/:\d+$/.test(raw)) u.port = "11434";
|
|
1336
|
+
if (u.hostname === "0.0.0.0") u.hostname = "127.0.0.1";
|
|
1337
|
+
return `${u.origin}/v1`;
|
|
1338
|
+
} catch {
|
|
1339
|
+
return AI_PROVIDERS.ollama.baseUrl;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async function fetchWithTimeout(url, init2, timeoutMs) {
|
|
1343
|
+
const controller = new AbortController();
|
|
1344
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1345
|
+
try {
|
|
1346
|
+
return await fetch(url, { ...init2, signal: controller.signal });
|
|
1347
|
+
} finally {
|
|
1348
|
+
clearTimeout(timer);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async function probeOllama(baseUrl, timeoutMs = 800) {
|
|
1352
|
+
try {
|
|
1353
|
+
const origin = new URL(baseUrl).origin;
|
|
1354
|
+
const res = await fetchWithTimeout(`${origin}/api/tags`, {}, timeoutMs);
|
|
1355
|
+
if (!res.ok) return null;
|
|
1356
|
+
const data = await res.json();
|
|
1357
|
+
return (data.models || []).map((m) => m.name || m.model || "").filter(Boolean);
|
|
1224
1358
|
} catch {
|
|
1225
1359
|
return null;
|
|
1226
1360
|
}
|
|
1227
1361
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
};
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1362
|
+
async function firstServerModel(baseUrl, apiKey) {
|
|
1363
|
+
try {
|
|
1364
|
+
const res = await fetchWithTimeout(`${baseUrl}/models`, {
|
|
1365
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
|
|
1366
|
+
}, 5e3);
|
|
1367
|
+
if (!res.ok) return {};
|
|
1368
|
+
const data = await res.json();
|
|
1369
|
+
return { model: data.data?.find((m) => m.id)?.id };
|
|
1370
|
+
} catch {
|
|
1371
|
+
return { unreachable: true };
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
function noProviderHints() {
|
|
1375
|
+
return [
|
|
1376
|
+
`Set one of ${AI_ENV_KEYS.join(", ")}, or pass --ai-key`,
|
|
1377
|
+
"Or run a local model: start Ollama (https://ollama.com) and `ollama pull llama3.2` - it is picked up automatically",
|
|
1378
|
+
"Or point at any OpenAI-compatible server: --ai-base-url http://localhost:8080/v1 --ai-model <name>"
|
|
1379
|
+
];
|
|
1380
|
+
}
|
|
1381
|
+
async function resolveAIProvider(opts, env = process.env, probe = probeOllama) {
|
|
1382
|
+
let provider = opts.aiProvider?.trim().toLowerCase();
|
|
1383
|
+
let notice;
|
|
1384
|
+
let ollamaModels = null;
|
|
1385
|
+
if (provider && !AI_PROVIDERS[provider]) {
|
|
1386
|
+
return {
|
|
1387
|
+
ok: false,
|
|
1388
|
+
error: `Unknown AI provider "${opts.aiProvider}"`,
|
|
1389
|
+
hints: [`Choose one of: ${AI_PROVIDER_IDS.join(", ")}`]
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
if (!provider) {
|
|
1393
|
+
if (opts.aiBaseUrl) {
|
|
1394
|
+
provider = "custom";
|
|
1395
|
+
} else if (opts.aiKey) {
|
|
1396
|
+
provider = providerFromKey(opts.aiKey) ?? "openai";
|
|
1397
|
+
} else {
|
|
1398
|
+
provider = DETECT_ORDER.find((id) => AI_PROVIDERS[id].envKeys.some((k) => env[k]));
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (!provider) {
|
|
1402
|
+
const base = ollamaBaseUrl(env);
|
|
1403
|
+
ollamaModels = await probe(base);
|
|
1404
|
+
if (ollamaModels === null) {
|
|
1405
|
+
return { ok: false, error: "AI analysis requested but no AI provider is configured", hints: noProviderHints() };
|
|
1406
|
+
}
|
|
1407
|
+
provider = "ollama";
|
|
1408
|
+
notice = `No AI key set - using local Ollama at ${base}`;
|
|
1409
|
+
}
|
|
1410
|
+
const preset = AI_PROVIDERS[provider];
|
|
1411
|
+
const baseUrl = (opts.aiBaseUrl || (provider === "ollama" ? ollamaBaseUrl(env) : preset.baseUrl) || "").replace(/\/+$/, "");
|
|
1412
|
+
if (!baseUrl) {
|
|
1413
|
+
return {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
error: "The custom provider needs --ai-base-url",
|
|
1416
|
+
hints: ["e.g. --ai-base-url http://localhost:8080/v1 --ai-model <name>"]
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
new URL(baseUrl);
|
|
1421
|
+
} catch {
|
|
1422
|
+
return { ok: false, error: `Invalid --ai-base-url "${opts.aiBaseUrl}"`, hints: ["Use a full URL such as http://localhost:11434/v1"] };
|
|
1423
|
+
}
|
|
1424
|
+
const apiKey = opts.aiKey || preset.envKeys.map((k) => env[k]).find(Boolean) || void 0;
|
|
1425
|
+
if (!apiKey && !preset.local) {
|
|
1426
|
+
return {
|
|
1427
|
+
ok: false,
|
|
1428
|
+
error: `No API key for ${preset.label}`,
|
|
1429
|
+
hints: [`Set ${preset.envKeys.join(" or ")}, or pass --ai-key`]
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
let model = opts.aiModel || preset.model;
|
|
1433
|
+
if (!model && provider === "ollama") {
|
|
1434
|
+
ollamaModels ??= await probe(baseUrl);
|
|
1435
|
+
if (ollamaModels === null) {
|
|
1436
|
+
return { ok: false, error: `Could not reach Ollama at ${baseUrl}`, hints: ["Start it with `ollama serve`, or set OLLAMA_HOST"] };
|
|
1437
|
+
}
|
|
1438
|
+
model = ollamaModels.find((name) => !/embed/i.test(name));
|
|
1439
|
+
if (!model) {
|
|
1440
|
+
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"] };
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (!model) {
|
|
1444
|
+
const found = await firstServerModel(baseUrl, apiKey);
|
|
1445
|
+
if (found.unreachable) {
|
|
1446
|
+
return { ok: false, error: `Could not reach ${preset.label} at ${baseUrl}`, hints: ["Is the server running? Or pass a different --ai-base-url"] };
|
|
1447
|
+
}
|
|
1448
|
+
model = found.model;
|
|
1449
|
+
if (!model) {
|
|
1450
|
+
return { ok: false, error: `${preset.label} at ${baseUrl} did not report a loaded model`, hints: ["Pass --ai-model <name>"] };
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (notice) notice += ` (model ${model})`;
|
|
1454
|
+
return {
|
|
1455
|
+
ok: true,
|
|
1456
|
+
ai: {
|
|
1457
|
+
provider,
|
|
1458
|
+
label: preset.label,
|
|
1459
|
+
baseUrl,
|
|
1460
|
+
model,
|
|
1461
|
+
apiKey,
|
|
1462
|
+
// A base-URL override may point anywhere; only trust JSON mode on the real endpoint.
|
|
1463
|
+
jsonMode: !!preset.jsonMode && !opts.aiBaseUrl,
|
|
1464
|
+
anthropic: !!preset.anthropic,
|
|
1465
|
+
local: !!preset.local,
|
|
1466
|
+
notice
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
function tryParse(text) {
|
|
1471
|
+
try {
|
|
1472
|
+
return JSON.parse(text);
|
|
1473
|
+
} catch {
|
|
1474
|
+
try {
|
|
1475
|
+
return JSON.parse(text.replace(/,\s*([}\]])/g, "$1"));
|
|
1476
|
+
} catch {
|
|
1477
|
+
return void 0;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
function balancedObjectAt(text, start) {
|
|
1482
|
+
let depth = 0;
|
|
1483
|
+
let inString = false;
|
|
1484
|
+
let escaped = false;
|
|
1485
|
+
for (let i = start; i < text.length; i++) {
|
|
1486
|
+
const ch = text[i];
|
|
1487
|
+
if (inString) {
|
|
1488
|
+
if (escaped) escaped = false;
|
|
1489
|
+
else if (ch === "\\") escaped = true;
|
|
1490
|
+
else if (ch === '"') inString = false;
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (ch === '"') inString = true;
|
|
1494
|
+
else if (ch === "{") depth++;
|
|
1495
|
+
else if (ch === "}") {
|
|
1496
|
+
depth--;
|
|
1497
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
function extractJsonObject(content) {
|
|
1503
|
+
const text = content.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
|
1504
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
1505
|
+
const sources = fenced ? [fenced[1], text] : [text];
|
|
1506
|
+
for (const source of sources) {
|
|
1507
|
+
let from = source.indexOf("{");
|
|
1508
|
+
let attempts = 0;
|
|
1509
|
+
while (from !== -1 && attempts++ < 50) {
|
|
1510
|
+
const candidate = balancedObjectAt(source, from);
|
|
1511
|
+
if (candidate) {
|
|
1512
|
+
const parsed = tryParse(candidate);
|
|
1513
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1514
|
+
}
|
|
1515
|
+
from = source.indexOf("{", from + 1);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return null;
|
|
1519
|
+
}
|
|
1520
|
+
function normalizeAnalysis(raw) {
|
|
1521
|
+
const score = Number(raw.contentScore);
|
|
1522
|
+
const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions.map((s) => typeof s === "string" ? s : JSON.stringify(s)).filter(Boolean) : [];
|
|
1523
|
+
const prioritizedFixes = Array.isArray(raw.prioritizedFixes) ? raw.prioritizedFixes.filter((f) => !!f && typeof f === "object" && "code" in f).map((f) => ({
|
|
1524
|
+
code: String(f.code),
|
|
1525
|
+
priority: Number(f.priority) || 3,
|
|
1526
|
+
rationale: String(f.rationale ?? "")
|
|
1527
|
+
})) : [];
|
|
1528
|
+
if (!Number.isFinite(score) && suggestions.length === 0 && prioritizedFixes.length === 0) return null;
|
|
1529
|
+
return {
|
|
1530
|
+
contentScore: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : 0,
|
|
1531
|
+
suggestions,
|
|
1532
|
+
prioritizedFixes
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
var SEVERITY_ORDER = { critical: 0, error: 1, warning: 2, notice: 3, info: 3 };
|
|
1536
|
+
var MAX_ISSUES_IN_PROMPT = 60;
|
|
1537
|
+
function buildPrompt(url, issues) {
|
|
1538
|
+
const ranked = [...issues].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4));
|
|
1539
|
+
const shown = ranked.slice(0, MAX_ISSUES_IN_PROMPT);
|
|
1540
|
+
const more = ranked.length > shown.length ? `
|
|
1541
|
+
(${ranked.length - shown.length} lower-severity issues omitted)` : "";
|
|
1542
|
+
return `Analyze these SEO audit results and provide prioritized recommendations:
|
|
1239
1543
|
|
|
1240
1544
|
URL: ${url}
|
|
1241
1545
|
|
|
1242
1546
|
Issues found:
|
|
1243
|
-
${
|
|
1547
|
+
${shown.map((i) => `- [${i.severity.toUpperCase()}] ${i.code}: ${i.title}`).join("\n")}${more}
|
|
1244
1548
|
|
|
1245
|
-
|
|
1549
|
+
Respond with only this JSON object - no prose, no code fences:
|
|
1246
1550
|
{
|
|
1247
1551
|
"contentScore": <0-100 overall content quality>,
|
|
1248
|
-
"suggestions": [<3-5 high-impact suggestions>],
|
|
1552
|
+
"suggestions": [<3-5 high-impact suggestions, as strings>],
|
|
1249
1553
|
"prioritizedFixes": [
|
|
1250
1554
|
{"code": "<issue code>", "priority": <1-5>, "rationale": "<why fix this first>"}
|
|
1251
1555
|
]
|
|
1252
1556
|
}`;
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1557
|
+
}
|
|
1558
|
+
async function runAIAnalysis(url, issues, ai) {
|
|
1559
|
+
const prompt = buildPrompt(url, issues);
|
|
1560
|
+
const system = "You are an SEO expert. Respond with valid JSON only.";
|
|
1561
|
+
const timeoutMs = ai.local ? 3e5 : 9e4;
|
|
1562
|
+
const maxTokens = 4096;
|
|
1563
|
+
const headers = { "Content-Type": "application/json" };
|
|
1564
|
+
let endpoint;
|
|
1565
|
+
let body;
|
|
1566
|
+
if (ai.anthropic) {
|
|
1567
|
+
endpoint = `${ai.baseUrl}/messages`;
|
|
1568
|
+
headers["x-api-key"] = ai.apiKey || "";
|
|
1569
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
1570
|
+
body = { model: ai.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: prompt }] };
|
|
1571
|
+
} else {
|
|
1572
|
+
endpoint = `${ai.baseUrl}/chat/completions`;
|
|
1573
|
+
if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
|
|
1574
|
+
if (ai.provider === "openrouter") {
|
|
1575
|
+
headers["HTTP-Referer"] = "https://rankcli.dev";
|
|
1576
|
+
headers["X-Title"] = "RankCLI";
|
|
1577
|
+
}
|
|
1578
|
+
body = {
|
|
1579
|
+
model: ai.model,
|
|
1580
|
+
messages: [
|
|
1581
|
+
{ role: "system", content: system },
|
|
1582
|
+
{ role: "user", content: prompt }
|
|
1583
|
+
],
|
|
1584
|
+
// OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
|
|
1585
|
+
...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
|
|
1586
|
+
...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
|
|
1256
1587
|
};
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1588
|
+
}
|
|
1589
|
+
let response;
|
|
1590
|
+
try {
|
|
1591
|
+
response = await fetchWithTimeout(endpoint, { method: "POST", headers, body: JSON.stringify(body) }, timeoutMs);
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
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);
|
|
1594
|
+
return { error: `${ai.label} request failed: ${reason}` };
|
|
1595
|
+
}
|
|
1596
|
+
if (!response.ok) {
|
|
1597
|
+
const detail = (await response.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 200);
|
|
1598
|
+
return { error: `${ai.label} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}` };
|
|
1599
|
+
}
|
|
1600
|
+
let data;
|
|
1601
|
+
try {
|
|
1602
|
+
data = await response.json();
|
|
1603
|
+
} catch {
|
|
1604
|
+
return { error: `${ai.label} returned a non-JSON response` };
|
|
1605
|
+
}
|
|
1606
|
+
const content = ai.anthropic ? data.content?.filter((b) => typeof b.text === "string").map((b) => b.text).join("") : data.choices?.[0]?.message?.content;
|
|
1607
|
+
if (!content) {
|
|
1608
|
+
return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
|
|
1609
|
+
}
|
|
1610
|
+
const parsed = extractJsonObject(content);
|
|
1611
|
+
const analysis = parsed ? normalizeAnalysis(parsed) : null;
|
|
1612
|
+
if (!analysis) {
|
|
1613
|
+
return { error: `${ai.label} (${ai.model}) did not return the requested JSON` };
|
|
1614
|
+
}
|
|
1615
|
+
return { analysis };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// src/commands/audit.ts
|
|
1619
|
+
var { detectFramework: detectFramework2, getFrameworkDisplayName } = frameworks;
|
|
1620
|
+
var EXIT_CODES = {
|
|
1621
|
+
/** Audit completed and no --fail-on / --min-score gate tripped. */
|
|
1622
|
+
OK: 0,
|
|
1623
|
+
/** A --fail-on or --min-score gate tripped. */
|
|
1624
|
+
GATE_FAILED: 1,
|
|
1625
|
+
/** Bad invocation: missing/invalid --url or an invalid flag value. */
|
|
1626
|
+
USAGE: 2,
|
|
1627
|
+
/** No real audit: the site was unreachable, the URL answered 4xx/5xx, no page could be analyzed, or an internal error. */
|
|
1628
|
+
AUDIT_FAILED: 3
|
|
1629
|
+
};
|
|
1630
|
+
var FAIL_ON_LEVELS = ["error", "warning", "notice"];
|
|
1631
|
+
var SEVERITY_RANK = { critical: 4, error: 3, warning: 2, notice: 1, info: 1 };
|
|
1632
|
+
function evaluateGate(issues, score, failOn, minScore) {
|
|
1633
|
+
const failures = [];
|
|
1634
|
+
if (failOn) {
|
|
1635
|
+
const threshold = SEVERITY_RANK[failOn];
|
|
1636
|
+
const hits = issues.filter((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
|
|
1637
|
+
if (hits.length > 0) {
|
|
1638
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1639
|
+
for (const i of hits) counts.set(i.severity, (counts.get(i.severity) || 0) + 1);
|
|
1640
|
+
const breakdown = [...counts.entries()].sort((a, b) => (SEVERITY_RANK[b[0]] ?? 0) - (SEVERITY_RANK[a[0]] ?? 0)).map(([severity, n]) => `${n} ${severity}`).join(", ");
|
|
1641
|
+
failures.push(`${hits.length} issue${hits.length === 1 ? "" : "s"} at or above --fail-on ${failOn} (${breakdown})`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
if (minScore !== void 0 && score < minScore) {
|
|
1645
|
+
failures.push(`score ${score} is below --min-score ${minScore}`);
|
|
1646
|
+
}
|
|
1647
|
+
return { failOn, minScore, passed: failures.length === 0, failures };
|
|
1648
|
+
}
|
|
1649
|
+
function parseGateOptions(options) {
|
|
1650
|
+
let failOn;
|
|
1651
|
+
let minScore;
|
|
1652
|
+
if (options.failOn !== void 0) {
|
|
1653
|
+
const level = String(options.failOn).trim().toLowerCase();
|
|
1654
|
+
if (!FAIL_ON_LEVELS.includes(level)) {
|
|
1655
|
+
return { error: `Invalid --fail-on "${options.failOn}" (use ${FAIL_ON_LEVELS.join(", ")})` };
|
|
1656
|
+
}
|
|
1657
|
+
failOn = level;
|
|
1658
|
+
}
|
|
1659
|
+
if (options.minScore !== void 0) {
|
|
1660
|
+
const raw = String(options.minScore).trim();
|
|
1661
|
+
const n = Number(raw);
|
|
1662
|
+
if (raw === "" || !Number.isFinite(n) || n < 0 || n > 100) {
|
|
1663
|
+
return { error: `Invalid --min-score "${options.minScore}" (use a number from 0 to 100)` };
|
|
1284
1664
|
}
|
|
1285
|
-
|
|
1665
|
+
minScore = n;
|
|
1666
|
+
}
|
|
1667
|
+
return { failOn, minScore };
|
|
1668
|
+
}
|
|
1669
|
+
async function fetchHtml(url, timeout = 1e4) {
|
|
1670
|
+
try {
|
|
1671
|
+
const controller = new AbortController();
|
|
1672
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1673
|
+
const response = await fetch(url, {
|
|
1674
|
+
signal: controller.signal,
|
|
1675
|
+
headers: {
|
|
1676
|
+
"User-Agent": "RankCLI-Bot/1.0 (+https://rankcli.dev/bot)",
|
|
1677
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
clearTimeout(timeoutId);
|
|
1286
1681
|
if (!response.ok) {
|
|
1287
|
-
console.error(chalk4.yellow(`AI analysis failed: ${response.status}`));
|
|
1288
1682
|
return null;
|
|
1289
1683
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
if (!content) return null;
|
|
1293
|
-
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
1294
|
-
if (!jsonMatch) return null;
|
|
1295
|
-
return JSON.parse(jsonMatch[0]);
|
|
1296
|
-
} catch (error) {
|
|
1297
|
-
console.error(chalk4.yellow(`AI analysis error: ${error}`));
|
|
1684
|
+
return await response.text();
|
|
1685
|
+
} catch {
|
|
1298
1686
|
return null;
|
|
1299
1687
|
}
|
|
1300
1688
|
}
|
|
@@ -1306,6 +1694,9 @@ function mergeIssues(issueArrays) {
|
|
|
1306
1694
|
if (existing) {
|
|
1307
1695
|
const urls = /* @__PURE__ */ new Set([...existing.affectedUrls || [], ...issue.affectedUrls || []]);
|
|
1308
1696
|
existing.affectedUrls = Array.from(urls);
|
|
1697
|
+
if ((SEVERITY_RANK[issue.severity] ?? 0) > (SEVERITY_RANK[existing.severity] ?? 0)) {
|
|
1698
|
+
existing.severity = issue.severity;
|
|
1699
|
+
}
|
|
1309
1700
|
} else {
|
|
1310
1701
|
issueMap.set(issue.code, { ...issue });
|
|
1311
1702
|
}
|
|
@@ -1351,9 +1742,9 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
|
|
|
1351
1742
|
const data = await res.json();
|
|
1352
1743
|
if (!res.ok) {
|
|
1353
1744
|
if (data.error === "project_limit_reached") {
|
|
1354
|
-
console.
|
|
1745
|
+
console.error(chalk4.yellow(`
|
|
1355
1746
|
\u26A0\uFE0F Project limit reached (${data.count}/${data.limit})`));
|
|
1356
|
-
console.
|
|
1747
|
+
console.error(chalk4.dim(` Upgrade at: ${WEB_APP_URL}/account`));
|
|
1357
1748
|
} else {
|
|
1358
1749
|
console.error(chalk4.dim(data.error || "Cloud sync failed"));
|
|
1359
1750
|
}
|
|
@@ -1369,60 +1760,70 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
|
|
|
1369
1760
|
return null;
|
|
1370
1761
|
}
|
|
1371
1762
|
}
|
|
1763
|
+
function printUsage(loggedIn) {
|
|
1764
|
+
console.error("\n\u26A0\uFE0F Please provide a URL to audit:");
|
|
1765
|
+
console.error(" rankcli audit --url https://your-site.com");
|
|
1766
|
+
console.error('\nRun "rankcli audit --help" for every option, exit codes, and AI providers.');
|
|
1767
|
+
if (!loggedIn) {
|
|
1768
|
+
console.error(chalk4.dim("\nTip: Log in to sync results to the web dashboard:"));
|
|
1769
|
+
console.error(chalk4.dim(" rankcli login"));
|
|
1770
|
+
}
|
|
1771
|
+
console.error("");
|
|
1772
|
+
}
|
|
1372
1773
|
async function audit(options) {
|
|
1373
1774
|
const url = options.url;
|
|
1374
|
-
const
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
groq: process.env.GROQ_API_KEY,
|
|
1383
|
-
gemini: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
|
1384
|
-
};
|
|
1385
|
-
const aiKey = options.aiKey || providerEnvKey[aiProvider] || "";
|
|
1386
|
-
const loggedIn = isLoggedIn();
|
|
1387
|
-
const { planId } = getUserInfo();
|
|
1388
|
-
const tierId = planId && planId in TIER_LIMITS ? planId : loggedIn ? "free" : "free_anon";
|
|
1389
|
-
const checksLimit = TIER_LIMITS[tierId].checks;
|
|
1390
|
-
let premiumChecks;
|
|
1391
|
-
if (checksLimit > TIER_LIMITS.free.checks) {
|
|
1392
|
-
const passphrase = getDecryptPassphrase();
|
|
1393
|
-
if (passphrase) {
|
|
1394
|
-
try {
|
|
1395
|
-
await initEngine(passphrase);
|
|
1396
|
-
premiumChecks = await getPremiumChecks();
|
|
1397
|
-
} catch (error) {
|
|
1398
|
-
console.error(chalk4.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
|
|
1399
|
-
}
|
|
1775
|
+
const json = options.output === "json";
|
|
1776
|
+
const fail = (code, message, extra = {}) => {
|
|
1777
|
+
if (json) {
|
|
1778
|
+
console.log(JSON.stringify({ url: url ?? null, error: message, exitCode: code, ...extra }, null, 2));
|
|
1779
|
+
} else {
|
|
1780
|
+
console.error(`
|
|
1781
|
+
\u274C ${message}
|
|
1782
|
+
`);
|
|
1400
1783
|
}
|
|
1401
|
-
|
|
1784
|
+
return code;
|
|
1785
|
+
};
|
|
1786
|
+
const gate = parseGateOptions(options);
|
|
1787
|
+
if (gate.error) return fail(EXIT_CODES.USAGE, gate.error);
|
|
1402
1788
|
if (!url) {
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
console.log(" --max-pages <n> Max pages to crawl (default: 5)");
|
|
1407
|
-
console.log(" --ai Enable AI analysis");
|
|
1408
|
-
console.log(" --ai-provider <p> AI provider: openai, anthropic, openrouter, groq, or gemini (auto-detected from env if omitted)");
|
|
1409
|
-
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)");
|
|
1410
|
-
console.log(" --output json Output as JSON");
|
|
1411
|
-
if (!loggedIn) {
|
|
1412
|
-
console.log(chalk4.dim("\nTip: Log in to sync results to the web dashboard:"));
|
|
1413
|
-
console.log(chalk4.dim(" rankcli login"));
|
|
1414
|
-
}
|
|
1415
|
-
console.log("");
|
|
1416
|
-
return;
|
|
1789
|
+
if (json) return fail(EXIT_CODES.USAGE, "No URL given - use --url https://your-site.com");
|
|
1790
|
+
printUsage(isLoggedIn());
|
|
1791
|
+
return EXIT_CODES.USAGE;
|
|
1417
1792
|
}
|
|
1418
1793
|
try {
|
|
1419
|
-
new URL(url);
|
|
1794
|
+
const parsed = new URL(url);
|
|
1795
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)");
|
|
1420
1796
|
} catch {
|
|
1421
|
-
|
|
1422
|
-
return;
|
|
1797
|
+
return fail(EXIT_CODES.USAGE, `Invalid URL "${url}" - use a full http(s) URL such as https://example.com`);
|
|
1423
1798
|
}
|
|
1424
|
-
|
|
1799
|
+
if (options.ai && options.aiProvider && !AI_PROVIDERS[options.aiProvider.trim().toLowerCase()]) {
|
|
1800
|
+
return fail(EXIT_CODES.USAGE, `Unknown --ai-provider "${options.aiProvider}" (use ${AI_PROVIDER_IDS.join(", ")})`);
|
|
1801
|
+
}
|
|
1802
|
+
const maxPages = Math.max(1, parseInt(options.maxPages || "5", 10) || 5);
|
|
1425
1803
|
try {
|
|
1804
|
+
const loggedIn = isLoggedIn();
|
|
1805
|
+
const keyPlan = await ensureApiKeyPlan();
|
|
1806
|
+
if (keyPlan?.status === "invalid") {
|
|
1807
|
+
console.error(chalk4.yellow(`\u26A0\uFE0F RankCLI API key rejected (${keyPlan.error}) - auditing on the Free tier.`));
|
|
1808
|
+
} else if (keyPlan?.status === "unavailable") {
|
|
1809
|
+
console.error(chalk4.dim(`(could not verify your RankCLI plan: ${keyPlan.error})`));
|
|
1810
|
+
}
|
|
1811
|
+
const { planId } = getUserInfo();
|
|
1812
|
+
const tierId = tierFromPlanId(planId) ?? (loggedIn ? "free" : "free_anon");
|
|
1813
|
+
const checksLimit = TIER_LIMITS[tierId].checks;
|
|
1814
|
+
let premiumChecks;
|
|
1815
|
+
if (checksLimit > TIER_LIMITS.free.checks) {
|
|
1816
|
+
const passphrase = getDecryptPassphrase();
|
|
1817
|
+
if (passphrase) {
|
|
1818
|
+
try {
|
|
1819
|
+
await initEngine(passphrase);
|
|
1820
|
+
premiumChecks = await getPremiumChecks();
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
console.error(chalk4.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const startTime = Date.now();
|
|
1426
1827
|
const discoverySpinner = ora2("Discovering pages...").start();
|
|
1427
1828
|
let pagesToAudit = [url];
|
|
1428
1829
|
const homepageHtml = await fetchHtml(url, 1e4);
|
|
@@ -1442,6 +1843,7 @@ async function audit(options) {
|
|
|
1442
1843
|
try {
|
|
1443
1844
|
const report = await runFullAudit({
|
|
1444
1845
|
url: pageUrl,
|
|
1846
|
+
auditRootUrl: url,
|
|
1445
1847
|
maxPages: 1,
|
|
1446
1848
|
checkBrokenLinks: options.checkLinks || false,
|
|
1447
1849
|
// Bundled with --check-links: both mean "verify actual link
|
|
@@ -1478,40 +1880,68 @@ async function audit(options) {
|
|
|
1478
1880
|
}
|
|
1479
1881
|
if (!primaryReport) {
|
|
1480
1882
|
auditSpinner.fail("Audit failed - could not analyze any pages");
|
|
1481
|
-
return;
|
|
1883
|
+
return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed - could not analyze ${url}`);
|
|
1884
|
+
}
|
|
1885
|
+
const pageFailure = primaryReport.issues.find((i) => i.code === "FETCH_ERROR" || i.code === "HTTP_ERROR_STATUS");
|
|
1886
|
+
if (pageFailure) {
|
|
1887
|
+
auditSpinner.fail(`Could not audit ${url}: ${pageFailure.title}`);
|
|
1888
|
+
} else {
|
|
1889
|
+
auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
|
|
1482
1890
|
}
|
|
1483
|
-
auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
|
|
1484
1891
|
const mergedIssues = mergeIssues(allIssues);
|
|
1892
|
+
const score = primaryReport.healthScore?.overall || 0;
|
|
1485
1893
|
let aiAnalysis = null;
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1894
|
+
let aiError;
|
|
1895
|
+
if (options.ai && !pageFailure) {
|
|
1896
|
+
const resolution = await resolveAIProvider({
|
|
1897
|
+
aiProvider: options.aiProvider,
|
|
1898
|
+
aiKey: options.aiKey,
|
|
1899
|
+
aiModel: options.aiModel,
|
|
1900
|
+
aiBaseUrl: options.aiBaseUrl
|
|
1901
|
+
});
|
|
1902
|
+
if (!resolution.ok) {
|
|
1903
|
+
aiError = resolution.error;
|
|
1904
|
+
console.error(chalk4.yellow(`
|
|
1905
|
+
\u26A0\uFE0F ${resolution.error}`));
|
|
1906
|
+
for (const hint of resolution.hints) console.error(chalk4.dim(` ${hint}`));
|
|
1491
1907
|
} else {
|
|
1492
|
-
|
|
1908
|
+
const { ai } = resolution;
|
|
1909
|
+
if (ai.notice) console.error(chalk4.dim(ai.notice));
|
|
1910
|
+
const aiSpinner = ora2(`Running AI analysis (${ai.label}, ${ai.model})...`).start();
|
|
1911
|
+
const result = await runAIAnalysis(url, mergedIssues, ai);
|
|
1912
|
+
if ("analysis" in result) {
|
|
1913
|
+
aiAnalysis = result.analysis;
|
|
1914
|
+
aiSpinner.succeed("AI analysis complete");
|
|
1915
|
+
} else {
|
|
1916
|
+
aiError = result.error;
|
|
1917
|
+
aiSpinner.warn(`AI analysis failed: ${result.error}`);
|
|
1918
|
+
}
|
|
1493
1919
|
}
|
|
1494
|
-
} else if (enableAI && !aiKey) {
|
|
1495
|
-
console.log(chalk4.yellow("\n\u26A0\uFE0F AI analysis requested but no API key provided"));
|
|
1496
|
-
console.log(chalk4.dim(" Set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY, or use --ai-key"));
|
|
1497
1920
|
}
|
|
1921
|
+
const gateResult = evaluateGate(mergedIssues, score, gate.failOn, gate.minScore);
|
|
1922
|
+
const gated = gate.failOn !== void 0 || gate.minScore !== void 0;
|
|
1923
|
+
const exitCode = pageFailure ? EXIT_CODES.AUDIT_FAILED : gateResult.passed ? EXIT_CODES.OK : EXIT_CODES.GATE_FAILED;
|
|
1498
1924
|
const totalTime = Date.now() - startTime;
|
|
1499
1925
|
const frameworkName = detectedFramework.framework !== "unknown" ? getFrameworkDisplayName(detectedFramework.framework) : null;
|
|
1500
1926
|
const actualChecksRun = (primaryReport.summary?.passed || 0) + (primaryReport.summary?.errors || 0) + (primaryReport.summary?.warnings || 0) || 200;
|
|
1501
|
-
if (
|
|
1927
|
+
if (json) {
|
|
1502
1928
|
let syncResult = null;
|
|
1503
1929
|
if (loggedIn) {
|
|
1504
1930
|
syncResult = await syncToCloud(url, primaryReport, mergedIssues, successCount, aiAnalysis, totalTime, actualChecksRun);
|
|
1505
1931
|
}
|
|
1506
1932
|
const jsonOutput = {
|
|
1507
1933
|
url,
|
|
1508
|
-
score
|
|
1934
|
+
score,
|
|
1509
1935
|
pagesAudited: successCount,
|
|
1510
1936
|
totalTime,
|
|
1511
1937
|
framework: frameworkName,
|
|
1512
1938
|
issues: mergedIssues,
|
|
1513
1939
|
healthScores: primaryReport.healthScore,
|
|
1514
1940
|
aiAnalysis,
|
|
1941
|
+
...aiError && { aiError },
|
|
1942
|
+
...pageFailure && { error: pageFailure.title },
|
|
1943
|
+
...gated && { gate: gateResult },
|
|
1944
|
+
exitCode,
|
|
1515
1945
|
// Include cloud sync info if synced
|
|
1516
1946
|
...syncResult && {
|
|
1517
1947
|
projectId: syncResult.projectId,
|
|
@@ -1520,11 +1950,13 @@ async function audit(options) {
|
|
|
1520
1950
|
}
|
|
1521
1951
|
};
|
|
1522
1952
|
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
1953
|
+
if (!gateResult.passed) {
|
|
1954
|
+
for (const failure of gateResult.failures) console.error(`Gate failed: ${failure}`);
|
|
1955
|
+
}
|
|
1523
1956
|
} else {
|
|
1524
1957
|
console.log("\n" + chalk4.bold("\u2550".repeat(60)));
|
|
1525
1958
|
console.log(chalk4.bold.cyan(` SEO AUDIT REPORT: ${url}`));
|
|
1526
1959
|
console.log(chalk4.bold("\u2550".repeat(60)));
|
|
1527
|
-
const score = primaryReport.healthScore?.overall || 0;
|
|
1528
1960
|
const scoreColor = score >= 80 ? chalk4.green : score >= 60 ? chalk4.yellow : chalk4.red;
|
|
1529
1961
|
console.log(`
|
|
1530
1962
|
${chalk4.bold("Score:")} ${scoreColor.bold(score + "/100")}`);
|
|
@@ -1551,9 +1983,9 @@ async function audit(options) {
|
|
|
1551
1983
|
console.log(` Local SEO: ${bar(hs.localSeo || 0)} ${hs.localSeo || 0}%`);
|
|
1552
1984
|
console.log(` AI Readiness: ${bar(hs.aiReadiness || 0)} ${hs.aiReadiness || 0}%`);
|
|
1553
1985
|
}
|
|
1554
|
-
const errors = mergedIssues.filter((i) => i.severity === "error");
|
|
1986
|
+
const errors = mergedIssues.filter((i) => i.severity === "error" || i.severity === "critical");
|
|
1555
1987
|
const warnings = mergedIssues.filter((i) => i.severity === "warning");
|
|
1556
|
-
const notices = mergedIssues.filter((i) => i.severity === "notice");
|
|
1988
|
+
const notices = mergedIssues.filter((i) => i.severity === "notice" || i.severity === "info");
|
|
1557
1989
|
console.log("\n" + chalk4.bold(" Issues by Severity:"));
|
|
1558
1990
|
console.log(` ${chalk4.red("\u25CF")} Errors: ${errors.length}`);
|
|
1559
1991
|
console.log(` ${chalk4.yellow("\u25CF")} Warnings: ${warnings.length}`);
|
|
@@ -1561,7 +1993,7 @@ async function audit(options) {
|
|
|
1561
1993
|
console.log("\n" + chalk4.bold(" Top Issues:"));
|
|
1562
1994
|
const topIssues = [...errors, ...warnings, ...notices].slice(0, 10);
|
|
1563
1995
|
topIssues.forEach((issue, i) => {
|
|
1564
|
-
const icon = issue.severity === "error" ? chalk4.red("\u2717") : issue.severity === "warning" ? chalk4.yellow("!") : chalk4.blue("i");
|
|
1996
|
+
const icon = issue.severity === "error" || issue.severity === "critical" ? chalk4.red("\u2717") : issue.severity === "warning" ? chalk4.yellow("!") : chalk4.blue("i");
|
|
1565
1997
|
console.log(` ${i + 1}. ${icon} ${issue.title}`);
|
|
1566
1998
|
if (issue.affectedUrls && issue.affectedUrls.length > 1) {
|
|
1567
1999
|
console.log(chalk4.dim(` Affects ${issue.affectedUrls.length} pages`));
|
|
@@ -1582,11 +2014,16 @@ async function audit(options) {
|
|
|
1582
2014
|
});
|
|
1583
2015
|
}
|
|
1584
2016
|
}
|
|
2017
|
+
if (gated) {
|
|
2018
|
+
console.log("");
|
|
2019
|
+
if (gateResult.passed) {
|
|
2020
|
+
console.log(chalk4.green(` \u2713 CI gate passed${gate.failOn ? ` (--fail-on ${gate.failOn})` : ""}${gate.minScore !== void 0 ? ` (--min-score ${gate.minScore})` : ""}`));
|
|
2021
|
+
} else {
|
|
2022
|
+
for (const failure of gateResult.failures) console.log(chalk4.red(` \u2717 CI gate failed: ${failure}`));
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
1585
2025
|
console.log("\n" + chalk4.bold("\u2550".repeat(60)));
|
|
1586
|
-
const
|
|
1587
|
-
const tier = userInfo.planName?.toLowerCase() || "free";
|
|
1588
|
-
const isFreeTier = !loggedIn || tier === "free";
|
|
1589
|
-
const isSoloOrBelow = isFreeTier || tier === "solo";
|
|
2026
|
+
const isFreeTier = tierId === "free" || tierId === "free_anon";
|
|
1590
2027
|
if (isFreeTier) {
|
|
1591
2028
|
const checksRun = mergedIssues.length > 0 ? loggedIn ? 100 : 50 : 0;
|
|
1592
2029
|
showUpsell("checks_limit", {
|
|
@@ -1612,13 +2049,46 @@ async function audit(options) {
|
|
|
1612
2049
|
} else {
|
|
1613
2050
|
showUpsell("free_audit_complete");
|
|
1614
2051
|
}
|
|
2052
|
+
if (pageFailure) {
|
|
2053
|
+
console.error(chalk4.red(`
|
|
2054
|
+
\u274C ${pageFailure.title}: ${pageFailure.description}
|
|
2055
|
+
`));
|
|
2056
|
+
}
|
|
1615
2057
|
}
|
|
2058
|
+
return exitCode;
|
|
1616
2059
|
} catch (error) {
|
|
1617
|
-
|
|
1618
|
-
\u274C Audit failed: ${error instanceof Error ? error.message : "Unknown error"}
|
|
1619
|
-
`);
|
|
2060
|
+
return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1620
2061
|
}
|
|
1621
2062
|
}
|
|
2063
|
+
var AUDIT_HELP_TEXT = `
|
|
2064
|
+
Exit codes:
|
|
2065
|
+
0 audit completed; no --fail-on / --min-score gate tripped
|
|
2066
|
+
1 a --fail-on or --min-score gate tripped
|
|
2067
|
+
2 bad invocation (missing/invalid --url, invalid flag value)
|
|
2068
|
+
3 no real audit: site unreachable, the URL answered 4xx/5xx, no page
|
|
2069
|
+
could be analyzed, or an internal error
|
|
2070
|
+
|
|
2071
|
+
CI gating (both opt-in; without them any completed audit exits 0):
|
|
2072
|
+
--fail-on error fail on any error
|
|
2073
|
+
--fail-on warning fail on any warning or error
|
|
2074
|
+
--fail-on notice fail on any issue at all
|
|
2075
|
+
--min-score 80 fail if the overall score is below 80
|
|
2076
|
+
|
|
2077
|
+
AI analysis (--ai) runs on your own provider. Hosted: ${Object.entries(AI_PROVIDERS).filter(([, p]) => !p.local).map(([id]) => id).join(", ")}.
|
|
2078
|
+
Picked automatically from the first key found in:
|
|
2079
|
+
${AI_ENV_KEYS.join(", ")}
|
|
2080
|
+
(OpenRouter first). No key at all? A running local Ollama is used.
|
|
2081
|
+
Local: --ai-provider ollama | lmstudio | llamacpp | vllm (no key needed;
|
|
2082
|
+
the loaded model is used unless you pass --ai-model), or any
|
|
2083
|
+
OpenAI-compatible server: --ai-base-url http://host:port/v1 --ai-model <name>
|
|
2084
|
+
|
|
2085
|
+
Examples:
|
|
2086
|
+
rankcli audit --url https://example.com
|
|
2087
|
+
rankcli audit --url https://example.com --fail-on error --min-score 80
|
|
2088
|
+
rankcli audit --url https://example.com -o json > report.json
|
|
2089
|
+
rankcli audit --url https://example.com --ai --ai-provider deepseek
|
|
2090
|
+
rankcli audit --url https://example.com --ai --ai-provider ollama --ai-model qwen3:8b
|
|
2091
|
+
`;
|
|
1622
2092
|
|
|
1623
2093
|
// src/commands/apply.ts
|
|
1624
2094
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
@@ -1900,6 +2370,29 @@ ${index + 1}. ${icon} ${fix2.issue.message}`);
|
|
|
1900
2370
|
}
|
|
1901
2371
|
}
|
|
1902
2372
|
async function fix(options) {
|
|
2373
|
+
const result = await runFix(options);
|
|
2374
|
+
if (options.output === "json" && !result.json) {
|
|
2375
|
+
result.json = JSON.stringify({
|
|
2376
|
+
url: result.url ?? options.url ?? null,
|
|
2377
|
+
success: result.success,
|
|
2378
|
+
...result.error && { error: result.error },
|
|
2379
|
+
dryRun: !!result.dryRun,
|
|
2380
|
+
fixes: (result.fixes || []).map((f) => ({
|
|
2381
|
+
code: f.issue.code,
|
|
2382
|
+
message: f.issue.message,
|
|
2383
|
+
file: f.file,
|
|
2384
|
+
applied: !!f.applied,
|
|
2385
|
+
skipped: f.skipped,
|
|
2386
|
+
skipReason: f.skipReason
|
|
2387
|
+
})),
|
|
2388
|
+
appliedCount: result.appliedCount ?? 0,
|
|
2389
|
+
skippedCount: result.skippedCount ?? 0,
|
|
2390
|
+
...result.durationMs !== void 0 && { durationMs: result.durationMs }
|
|
2391
|
+
}, null, 2);
|
|
2392
|
+
}
|
|
2393
|
+
return result;
|
|
2394
|
+
}
|
|
2395
|
+
async function runFix(options) {
|
|
1903
2396
|
const startTime = Date.now();
|
|
1904
2397
|
const cwd = options.cwd || process.cwd();
|
|
1905
2398
|
const url = options.url;
|
|
@@ -1924,8 +2417,9 @@ async function fix(options) {
|
|
|
1924
2417
|
return { success: false, error };
|
|
1925
2418
|
}
|
|
1926
2419
|
const loggedIn = isLoggedIn();
|
|
2420
|
+
if (loggedIn) await ensureApiKeyPlan();
|
|
1927
2421
|
const userInfo = getUserInfo();
|
|
1928
|
-
const tier = userInfo.planName
|
|
2422
|
+
const tier = tierFromPlanId(userInfo.planId) ?? tierFromPlanId(userInfo.planName) ?? "free";
|
|
1929
2423
|
const hasAutoFix = loggedIn && hasFeature(tier, "auto_fix");
|
|
1930
2424
|
if (!options.dryRun && !hasAutoFix) {
|
|
1931
2425
|
if (options.output !== "json") {
|
|
@@ -3414,17 +3908,21 @@ async function testWebhook(channel, webhookUrl) {
|
|
|
3414
3908
|
}
|
|
3415
3909
|
|
|
3416
3910
|
// src/version.ts
|
|
3417
|
-
var VERSION = "0.0.
|
|
3911
|
+
var VERSION = "0.0.30";
|
|
3418
3912
|
|
|
3419
3913
|
// src/index.ts
|
|
3420
3914
|
var program = new Command();
|
|
3421
3915
|
program.name("rankcli").description("RankCLI - Ship code, get ranked. SEO meets CI/CD.").version(VERSION);
|
|
3422
|
-
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(
|
|
3916
|
+
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) => {
|
|
3917
|
+
process.exitCode = await login(options);
|
|
3918
|
+
});
|
|
3423
3919
|
program.command("logout").description("Log out of your RankCLI account").action(logout);
|
|
3424
3920
|
program.command("whoami").description("Show current logged in user").action(whoami);
|
|
3425
3921
|
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);
|
|
3426
3922
|
program.command("analyze").description("AI analyzes your codebase for SEO opportunities").option("-v, --verbose", "Show detailed output").action(analyze);
|
|
3427
|
-
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,
|
|
3923
|
+
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) => {
|
|
3924
|
+
process.exitCode = await audit(options);
|
|
3925
|
+
});
|
|
3428
3926
|
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);
|
|
3429
3927
|
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) => {
|
|
3430
3928
|
const result = await fix({
|
|
@@ -3505,7 +4003,10 @@ program.command("report").description("Generate SEO performance report").option(
|
|
|
3505
4003
|
program.command("watch").description("Continuous monitoring mode").action(() => {
|
|
3506
4004
|
console.log("Watch mode coming soon...");
|
|
3507
4005
|
});
|
|
3508
|
-
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);
|
|
4006
|
+
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);
|
|
3509
4007
|
program.command("gsc-setup").description("Show Google Search Console API setup instructions").action(showGSCInstructions);
|
|
3510
4008
|
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);
|
|
3511
|
-
program.
|
|
4009
|
+
program.parseAsync().catch((error) => {
|
|
4010
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
4011
|
+
process.exitCode = 1;
|
|
4012
|
+
});
|