@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/dist/index.mjs CHANGED
@@ -22,22 +22,23 @@ 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() {
28
- const url = process.env.RANKCLI_SUPABASE_URL || process.env.VITE_SUPABASE_URL || INJECTED_CONFIG?.supabaseUrl;
29
+ const url = process.env.RANKCLI_SUPABASE_URL || INJECTED_CONFIG?.supabaseUrl;
29
30
  if (!url) {
30
31
  throw new Error(
31
- "Supabase URL not configured. Set RANKCLI_SUPABASE_URL environment variable.\nSee: https://rankcli.dev/docs/cli-setup"
32
+ "Supabase URL not configured. Set RANKCLI_SUPABASE_URL environment variable.\nSee: https://rankcli.dev/docs#getting-started"
32
33
  );
33
34
  }
34
35
  return url;
35
36
  }
36
37
  function getSupabaseAnonKey() {
37
- const key = process.env.RANKCLI_SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || INJECTED_CONFIG?.supabaseAnonKey;
38
+ const key = process.env.RANKCLI_SUPABASE_ANON_KEY || INJECTED_CONFIG?.supabaseAnonKey;
38
39
  if (!key) {
39
40
  throw new Error(
40
- "Supabase anon key not configured. Set RANKCLI_SUPABASE_ANON_KEY environment variable.\nSee: https://rankcli.dev/docs/cli-setup"
41
+ "Supabase anon key not configured. Set RANKCLI_SUPABASE_ANON_KEY environment variable.\nSee: https://rankcli.dev/docs#getting-started"
41
42
  );
42
43
  }
43
44
  return key;
@@ -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 || "Invalid API key" };
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 fetchAndSaveSubscription();
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 apiKey = getApiKey();
398
- if (apiKey) {
399
- const result = await validateApiKey(apiKey);
400
- if (result.valid && result.subscription) {
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
- await setupGitHubAction(projectPath, options.schedule || "weekly");
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
- autoFix: true,
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: true
694
+ createPRs: !!syncToDashboard
620
695
  },
621
696
  syncToDashboard
622
697
  };
@@ -1022,23 +1097,25 @@ import chalk4 from "chalk";
1022
1097
  import chalk3 from "chalk";
1023
1098
  var WEB_APP_URL2 = "https://rankcli.dev";
1024
1099
  var TIER_LIMITS = {
1100
+ // Every plan runs every check (pricing-tiers.json: free_audit / free_registered 280). Plans
1101
+ // differ in sites, schedules, pages per audit, PRs and alerts - not in what an audit checks.
1025
1102
  free_anon: {
1026
- checks: 50,
1103
+ checks: 280,
1027
1104
  sites: 0,
1028
1105
  domains_per_month: 1,
1029
- features: { geo: false, auto_fix: false, github: false, ai: false }
1106
+ features: { geo: true, auto_fix: true, github: false, ai: false }
1030
1107
  },
1031
1108
  free: {
1032
- checks: 100,
1109
+ checks: 280,
1033
1110
  sites: 1,
1034
1111
  domains_per_month: 2,
1035
- features: { geo: "basic", auto_fix: false, github: false, ai: false }
1112
+ features: { geo: true, auto_fix: true, github: true, ai: false }
1036
1113
  },
1037
1114
  solo: {
1038
1115
  checks: 280,
1039
1116
  sites: 3,
1040
1117
  domains_per_month: 5,
1041
- features: { geo: true, auto_fix: true, github: false, ai: true }
1118
+ features: { geo: true, auto_fix: true, github: true, ai: true }
1042
1119
  },
1043
1120
  pro: {
1044
1121
  checks: 280,
@@ -1054,26 +1131,24 @@ var TIER_LIMITS = {
1054
1131
  features: { geo: true, auto_fix: true, github: true, ai: true }
1055
1132
  }
1056
1133
  };
1134
+ function tierFromPlanId(planId) {
1135
+ if (!planId) return void 0;
1136
+ const base = planId.toLowerCase().replace(/^ltd_/, "").replace(/_(annual|yearly|monthly|founding|lifetime|ltd)$/, "");
1137
+ return base === "free" || base === "solo" || base === "pro" || base === "agency" ? base : void 0;
1138
+ }
1057
1139
  var UPSELL_MESSAGES = {
1058
1140
  // After free audit completes
1059
1141
  free_audit_complete: () => [
1060
1142
  "",
1061
1143
  chalk3.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"),
1062
- chalk3.yellow.bold(" \u{1F680} Get 2x more checks + audit history"),
1063
- chalk3.dim(" Create a free account to unlock 100 checks and save your results."),
1144
+ chalk3.yellow.bold(" \u{1F680} Keep this site audited, free"),
1145
+ chalk3.dim(" A free account audits one site weekly, keeps the history and"),
1146
+ chalk3.dim(" emails you a weekly report."),
1064
1147
  "",
1065
1148
  chalk3.cyan(` \u2192 rankcli login`),
1066
1149
  chalk3.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"),
1067
1150
  ""
1068
1151
  ],
1069
- // When checks limit is reached
1070
- checks_limit: (ctx) => [
1071
- "",
1072
- chalk3.yellow(`\u26A0\uFE0F Running ${ctx.current || 50} of ${TIER_LIMITS.solo.checks} available checks`),
1073
- chalk3.dim(` Unlock all ${TIER_LIMITS.solo.checks}+ checks with Solo ($9/mo)`),
1074
- chalk3.cyan(` \u2192 ${WEB_APP_URL2}/pricing?from=cli_checks`),
1075
- ""
1076
- ],
1077
1152
  // When domain limit is reached
1078
1153
  domain_limit: (ctx) => [
1079
1154
  "",
@@ -1086,18 +1161,6 @@ var UPSELL_MESSAGES = {
1086
1161
  chalk3.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"),
1087
1162
  ""
1088
1163
  ],
1089
- // When GEO features are limited
1090
- geo_limited: (ctx) => [
1091
- "",
1092
- chalk3.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"),
1093
- chalk3.yellow.bold(" \u{1F916} Full GEO analysis requires Solo"),
1094
- chalk3.dim(" You're seeing basic AI crawler access checks."),
1095
- chalk3.dim(" Full analysis includes: LLM signals, citation readiness, content structure"),
1096
- "",
1097
- chalk3.cyan(` \u2192 ${WEB_APP_URL2}/pricing?from=cli_geo`),
1098
- chalk3.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"),
1099
- ""
1100
- ],
1101
1164
  // When auto-fix is attempted without access
1102
1165
  auto_fix_blocked: () => [
1103
1166
  "",
@@ -1198,108 +1261,407 @@ function showUpsell(messageKey, context) {
1198
1261
  const lines = messageFn(ctx);
1199
1262
  lines.forEach((line) => console.log(line));
1200
1263
  }
1201
- function hasFeature(tier, feature) {
1202
- const limits = TIER_LIMITS[tier];
1203
- return limits?.features?.[feature] ?? false;
1204
- }
1205
1264
 
1206
1265
  // src/models.ts
1207
1266
  var OPENAI_FAST = "gpt-5.6-luna";
1208
1267
  var ANTHROPIC_FAST = "claude-haiku-4-5-20251001";
1268
+ var OPENROUTER_DEFAULT = "openrouter/free";
1269
+ var GROQ_DEFAULT = "llama-3.3-70b-versatile";
1270
+ var GEMINI_DEFAULT = "gemini-3.7-flash";
1271
+ var DEEPSEEK_DEFAULT = "deepseek-flash";
1272
+ var MISTRAL_DEFAULT = "mistral-small-latest";
1273
+ var TOGETHER_DEFAULT = "openai/gpt-oss-120b";
1274
+ var FIREWORKS_DEFAULT = "accounts/fireworks/models/gpt-oss-120b";
1275
+ var CEREBRAS_DEFAULT = "gpt-oss-120b";
1209
1276
 
1210
- // src/commands/audit.ts
1211
- var { detectFramework: detectFramework2, getFrameworkDisplayName } = frameworks;
1212
- async function fetchHtml(url, timeout = 1e4) {
1277
+ // src/lib/ai.ts
1278
+ var AI_PROVIDERS = {
1279
+ // Hosted
1280
+ openrouter: { label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", model: OPENROUTER_DEFAULT, envKeys: ["OPENROUTER_API_KEY"] },
1281
+ openai: { label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: OPENAI_FAST, envKeys: ["OPENAI_API_KEY"], jsonMode: true },
1282
+ anthropic: { label: "Anthropic", baseUrl: "https://api.anthropic.com/v1", model: ANTHROPIC_FAST, envKeys: ["ANTHROPIC_API_KEY"], anthropic: true },
1283
+ groq: { label: "Groq", baseUrl: "https://api.groq.com/openai/v1", model: GROQ_DEFAULT, envKeys: ["GROQ_API_KEY"], jsonMode: true },
1284
+ gemini: { label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: GEMINI_DEFAULT, envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"] },
1285
+ deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com", model: DEEPSEEK_DEFAULT, envKeys: ["DEEPSEEK_API_KEY"], jsonMode: true },
1286
+ mistral: { label: "Mistral", baseUrl: "https://api.mistral.ai/v1", model: MISTRAL_DEFAULT, envKeys: ["MISTRAL_API_KEY"], jsonMode: true },
1287
+ cerebras: { label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", model: CEREBRAS_DEFAULT, envKeys: ["CEREBRAS_API_KEY"] },
1288
+ together: { label: "Together", baseUrl: "https://api.together.xyz/v1", model: TOGETHER_DEFAULT, envKeys: ["TOGETHER_API_KEY"] },
1289
+ fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", model: FIREWORKS_DEFAULT, envKeys: ["FIREWORKS_API_KEY"] },
1290
+ // Local / self-hosted
1291
+ ollama: { label: "Ollama", baseUrl: "http://localhost:11434/v1", envKeys: [], local: true },
1292
+ lmstudio: { label: "LM Studio", baseUrl: "http://localhost:1234/v1", envKeys: [], local: true },
1293
+ llamacpp: { label: "llama.cpp", baseUrl: "http://localhost:8080/v1", envKeys: [], local: true },
1294
+ vllm: { label: "vLLM", baseUrl: "http://localhost:8000/v1", envKeys: [], local: true },
1295
+ custom: { label: "OpenAI-compatible server", envKeys: [], local: true }
1296
+ };
1297
+ var AI_PROVIDER_IDS = Object.keys(AI_PROVIDERS);
1298
+ var DETECT_ORDER = ["openrouter", "groq", "gemini", "deepseek", "cerebras", "mistral", "together", "fireworks", "anthropic", "openai"];
1299
+ var AI_ENV_KEYS = DETECT_ORDER.flatMap((id) => AI_PROVIDERS[id].envKeys);
1300
+ function providerFromKey(key) {
1301
+ if (key.startsWith("sk-or-")) return "openrouter";
1302
+ if (key.startsWith("sk-ant-")) return "anthropic";
1303
+ if (key.startsWith("gsk_")) return "groq";
1304
+ if (key.startsWith("AIza")) return "gemini";
1305
+ if (key.startsWith("csk-")) return "cerebras";
1306
+ return void 0;
1307
+ }
1308
+ function ollamaBaseUrl(env) {
1309
+ const raw = env.OLLAMA_HOST?.trim();
1310
+ if (!raw) return AI_PROVIDERS.ollama.baseUrl;
1213
1311
  try {
1214
- const controller = new AbortController();
1215
- const timeoutId = setTimeout(() => controller.abort(), timeout);
1216
- const response = await fetch(url, {
1217
- signal: controller.signal,
1218
- headers: {
1219
- "User-Agent": "RankCLI-Bot/1.0 (+https://rankcli.dev/bot)",
1220
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1221
- }
1222
- });
1223
- clearTimeout(timeoutId);
1224
- if (!response.ok) {
1225
- return null;
1226
- }
1227
- return await response.text();
1312
+ const hasScheme = /^https?:\/\//i.test(raw);
1313
+ const u = new URL(hasScheme ? raw : `http://${raw}`);
1314
+ if (!hasScheme && !/:\d+$/.test(raw)) u.port = "11434";
1315
+ if (u.hostname === "0.0.0.0") u.hostname = "127.0.0.1";
1316
+ return `${u.origin}/v1`;
1317
+ } catch {
1318
+ return AI_PROVIDERS.ollama.baseUrl;
1319
+ }
1320
+ }
1321
+ async function fetchWithTimeout(url, init2, timeoutMs) {
1322
+ const controller = new AbortController();
1323
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1324
+ try {
1325
+ return await fetch(url, { ...init2, signal: controller.signal });
1326
+ } finally {
1327
+ clearTimeout(timer);
1328
+ }
1329
+ }
1330
+ async function probeOllama(baseUrl, timeoutMs = 800) {
1331
+ try {
1332
+ const origin = new URL(baseUrl).origin;
1333
+ const res = await fetchWithTimeout(`${origin}/api/tags`, {}, timeoutMs);
1334
+ if (!res.ok) return null;
1335
+ const data = await res.json();
1336
+ return (data.models || []).map((m) => m.name || m.model || "").filter(Boolean);
1228
1337
  } catch {
1229
1338
  return null;
1230
1339
  }
1231
1340
  }
1232
- var AI_PROVIDER_DEFAULTS = {
1233
- openai: { baseUrl: "https://api.openai.com/v1", model: OPENAI_FAST },
1234
- openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "deepseek/deepseek-r1:free" },
1235
- groq: { baseUrl: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile" },
1236
- gemini: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-2.0-flash" }
1237
- };
1238
- async function runAIAnalysis(url, issues, provider, apiKey) {
1239
- const isAnthropic = provider === "anthropic";
1240
- const providerConfig = AI_PROVIDER_DEFAULTS[provider] || AI_PROVIDER_DEFAULTS.openai;
1241
- const apiUrl = isAnthropic ? "https://api.anthropic.com/v1/messages" : `${providerConfig.baseUrl}/chat/completions`;
1242
- const prompt = `Analyze these SEO audit results and provide prioritized recommendations:
1341
+ async function firstServerModel(baseUrl, apiKey) {
1342
+ try {
1343
+ const res = await fetchWithTimeout(`${baseUrl}/models`, {
1344
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
1345
+ }, 5e3);
1346
+ if (!res.ok) return {};
1347
+ const data = await res.json();
1348
+ return { model: data.data?.find((m) => m.id)?.id };
1349
+ } catch {
1350
+ return { unreachable: true };
1351
+ }
1352
+ }
1353
+ function noProviderHints() {
1354
+ return [
1355
+ `Set one of ${AI_ENV_KEYS.join(", ")}, or pass --ai-key`,
1356
+ "Or run a local model: start Ollama (https://ollama.com) and `ollama pull llama3.2` - it is picked up automatically",
1357
+ "Or point at any OpenAI-compatible server: --ai-base-url http://localhost:8080/v1 --ai-model <name>"
1358
+ ];
1359
+ }
1360
+ async function resolveAIProvider(opts, env = process.env, probe = probeOllama) {
1361
+ let provider = opts.aiProvider?.trim().toLowerCase();
1362
+ let notice;
1363
+ let ollamaModels = null;
1364
+ if (provider && !AI_PROVIDERS[provider]) {
1365
+ return {
1366
+ ok: false,
1367
+ error: `Unknown AI provider "${opts.aiProvider}"`,
1368
+ hints: [`Choose one of: ${AI_PROVIDER_IDS.join(", ")}`]
1369
+ };
1370
+ }
1371
+ if (!provider) {
1372
+ if (opts.aiBaseUrl) {
1373
+ provider = "custom";
1374
+ } else if (opts.aiKey) {
1375
+ provider = providerFromKey(opts.aiKey) ?? "openai";
1376
+ } else {
1377
+ provider = DETECT_ORDER.find((id) => AI_PROVIDERS[id].envKeys.some((k) => env[k]));
1378
+ }
1379
+ }
1380
+ if (!provider) {
1381
+ const base = ollamaBaseUrl(env);
1382
+ ollamaModels = await probe(base);
1383
+ if (ollamaModels === null) {
1384
+ return { ok: false, error: "AI analysis requested but no AI provider is configured", hints: noProviderHints() };
1385
+ }
1386
+ provider = "ollama";
1387
+ notice = `No AI key set - using local Ollama at ${base}`;
1388
+ }
1389
+ const preset = AI_PROVIDERS[provider];
1390
+ const baseUrl = (opts.aiBaseUrl || (provider === "ollama" ? ollamaBaseUrl(env) : preset.baseUrl) || "").replace(/\/+$/, "");
1391
+ if (!baseUrl) {
1392
+ return {
1393
+ ok: false,
1394
+ error: "The custom provider needs --ai-base-url",
1395
+ hints: ["e.g. --ai-base-url http://localhost:8080/v1 --ai-model <name>"]
1396
+ };
1397
+ }
1398
+ try {
1399
+ new URL(baseUrl);
1400
+ } catch {
1401
+ return { ok: false, error: `Invalid --ai-base-url "${opts.aiBaseUrl}"`, hints: ["Use a full URL such as http://localhost:11434/v1"] };
1402
+ }
1403
+ const apiKey = opts.aiKey || preset.envKeys.map((k) => env[k]).find(Boolean) || void 0;
1404
+ if (!apiKey && !preset.local) {
1405
+ return {
1406
+ ok: false,
1407
+ error: `No API key for ${preset.label}`,
1408
+ hints: [`Set ${preset.envKeys.join(" or ")}, or pass --ai-key`]
1409
+ };
1410
+ }
1411
+ let model = opts.aiModel || preset.model;
1412
+ if (!model && provider === "ollama") {
1413
+ ollamaModels ??= await probe(baseUrl);
1414
+ if (ollamaModels === null) {
1415
+ return { ok: false, error: `Could not reach Ollama at ${baseUrl}`, hints: ["Start it with `ollama serve`, or set OLLAMA_HOST"] };
1416
+ }
1417
+ model = ollamaModels.find((name) => !/embed/i.test(name));
1418
+ if (!model) {
1419
+ 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"] };
1420
+ }
1421
+ }
1422
+ if (!model) {
1423
+ const found = await firstServerModel(baseUrl, apiKey);
1424
+ if (found.unreachable) {
1425
+ return { ok: false, error: `Could not reach ${preset.label} at ${baseUrl}`, hints: ["Is the server running? Or pass a different --ai-base-url"] };
1426
+ }
1427
+ model = found.model;
1428
+ if (!model) {
1429
+ return { ok: false, error: `${preset.label} at ${baseUrl} did not report a loaded model`, hints: ["Pass --ai-model <name>"] };
1430
+ }
1431
+ }
1432
+ if (notice) notice += ` (model ${model})`;
1433
+ return {
1434
+ ok: true,
1435
+ ai: {
1436
+ provider,
1437
+ label: preset.label,
1438
+ baseUrl,
1439
+ model,
1440
+ apiKey,
1441
+ // A base-URL override may point anywhere; only trust JSON mode on the real endpoint.
1442
+ jsonMode: !!preset.jsonMode && !opts.aiBaseUrl,
1443
+ anthropic: !!preset.anthropic,
1444
+ local: !!preset.local,
1445
+ notice
1446
+ }
1447
+ };
1448
+ }
1449
+ function tryParse(text) {
1450
+ try {
1451
+ return JSON.parse(text);
1452
+ } catch {
1453
+ try {
1454
+ return JSON.parse(text.replace(/,\s*([}\]])/g, "$1"));
1455
+ } catch {
1456
+ return void 0;
1457
+ }
1458
+ }
1459
+ }
1460
+ function balancedObjectAt(text, start) {
1461
+ let depth = 0;
1462
+ let inString = false;
1463
+ let escaped = false;
1464
+ for (let i = start; i < text.length; i++) {
1465
+ const ch = text[i];
1466
+ if (inString) {
1467
+ if (escaped) escaped = false;
1468
+ else if (ch === "\\") escaped = true;
1469
+ else if (ch === '"') inString = false;
1470
+ continue;
1471
+ }
1472
+ if (ch === '"') inString = true;
1473
+ else if (ch === "{") depth++;
1474
+ else if (ch === "}") {
1475
+ depth--;
1476
+ if (depth === 0) return text.slice(start, i + 1);
1477
+ }
1478
+ }
1479
+ return null;
1480
+ }
1481
+ function extractJsonObject(content) {
1482
+ const text = content.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
1483
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
1484
+ const sources = fenced ? [fenced[1], text] : [text];
1485
+ for (const source of sources) {
1486
+ let from = source.indexOf("{");
1487
+ let attempts = 0;
1488
+ while (from !== -1 && attempts++ < 50) {
1489
+ const candidate = balancedObjectAt(source, from);
1490
+ if (candidate) {
1491
+ const parsed = tryParse(candidate);
1492
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1493
+ }
1494
+ from = source.indexOf("{", from + 1);
1495
+ }
1496
+ }
1497
+ return null;
1498
+ }
1499
+ function normalizeAnalysis(raw) {
1500
+ const score = Number(raw.contentScore);
1501
+ const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions.map((s) => typeof s === "string" ? s : JSON.stringify(s)).filter(Boolean) : [];
1502
+ const prioritizedFixes = Array.isArray(raw.prioritizedFixes) ? raw.prioritizedFixes.filter((f) => !!f && typeof f === "object" && "code" in f).map((f) => ({
1503
+ code: String(f.code),
1504
+ priority: Number(f.priority) || 3,
1505
+ rationale: String(f.rationale ?? "")
1506
+ })) : [];
1507
+ if (!Number.isFinite(score) && suggestions.length === 0 && prioritizedFixes.length === 0) return null;
1508
+ return {
1509
+ contentScore: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : 0,
1510
+ suggestions,
1511
+ prioritizedFixes
1512
+ };
1513
+ }
1514
+ var SEVERITY_ORDER = { critical: 0, error: 1, warning: 2, notice: 3, info: 3 };
1515
+ var MAX_ISSUES_IN_PROMPT = 60;
1516
+ function buildPrompt(url, issues) {
1517
+ const ranked = [...issues].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4));
1518
+ const shown = ranked.slice(0, MAX_ISSUES_IN_PROMPT);
1519
+ const more = ranked.length > shown.length ? `
1520
+ (${ranked.length - shown.length} lower-severity issues omitted)` : "";
1521
+ return `Analyze these SEO audit results and provide prioritized recommendations:
1243
1522
 
1244
1523
  URL: ${url}
1245
1524
 
1246
1525
  Issues found:
1247
- ${issues.map((i) => `- [${i.severity.toUpperCase()}] ${i.code}: ${i.title}`).join("\n")}
1526
+ ${shown.map((i) => `- [${i.severity.toUpperCase()}] ${i.code}: ${i.title}`).join("\n")}${more}
1248
1527
 
1249
- Return JSON with:
1528
+ Respond with only this JSON object - no prose, no code fences:
1250
1529
  {
1251
1530
  "contentScore": <0-100 overall content quality>,
1252
- "suggestions": [<3-5 high-impact suggestions>],
1531
+ "suggestions": [<3-5 high-impact suggestions, as strings>],
1253
1532
  "prioritizedFixes": [
1254
1533
  {"code": "<issue code>", "priority": <1-5>, "rationale": "<why fix this first>"}
1255
1534
  ]
1256
1535
  }`;
1257
- try {
1258
- const headers = {
1259
- "Content-Type": "application/json"
1536
+ }
1537
+ async function runAIAnalysis(url, issues, ai) {
1538
+ const prompt = buildPrompt(url, issues);
1539
+ const system = "You are an SEO expert. Respond with valid JSON only.";
1540
+ const timeoutMs = ai.local ? 3e5 : 9e4;
1541
+ const maxTokens = 4096;
1542
+ const headers = { "Content-Type": "application/json" };
1543
+ let endpoint;
1544
+ let body;
1545
+ if (ai.anthropic) {
1546
+ endpoint = `${ai.baseUrl}/messages`;
1547
+ headers["x-api-key"] = ai.apiKey || "";
1548
+ headers["anthropic-version"] = "2023-06-01";
1549
+ body = { model: ai.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: prompt }] };
1550
+ } else {
1551
+ endpoint = `${ai.baseUrl}/chat/completions`;
1552
+ if (ai.apiKey) headers["Authorization"] = `Bearer ${ai.apiKey}`;
1553
+ if (ai.provider === "openrouter") {
1554
+ headers["HTTP-Referer"] = "https://rankcli.dev";
1555
+ headers["X-Title"] = "RankCLI";
1556
+ }
1557
+ body = {
1558
+ model: ai.model,
1559
+ messages: [
1560
+ { role: "system", content: system },
1561
+ { role: "user", content: prompt }
1562
+ ],
1563
+ // OpenAI's GPT-5.6 rejects max_tokens (and temperature); everyone else expects max_tokens.
1564
+ ...ai.provider === "openai" && !ai.local ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
1565
+ ...ai.jsonMode ? { response_format: { type: "json_object" } } : {}
1260
1566
  };
1261
- let body;
1262
- if (isAnthropic) {
1263
- headers["x-api-key"] = apiKey;
1264
- headers["anthropic-version"] = "2023-06-01";
1265
- body = JSON.stringify({
1266
- model: ANTHROPIC_FAST,
1267
- max_tokens: 1500,
1268
- messages: [{ role: "user", content: prompt }]
1269
- });
1270
- } else {
1271
- headers["Authorization"] = `Bearer ${apiKey}`;
1272
- if (provider === "openrouter") {
1273
- headers["HTTP-Referer"] = "https://rankcli.dev";
1274
- headers["X-Title"] = "RankCLI";
1275
- }
1276
- body = JSON.stringify({
1277
- model: providerConfig.model,
1278
- messages: [
1279
- { role: "system", content: "You are an SEO expert. Respond with valid JSON only." },
1280
- { role: "user", content: prompt }
1281
- ],
1282
- // OpenAI's GPT-5.6 rejects max_tokens; other OpenAI-compatible servers still expect it.
1283
- ...provider === "openai" ? { max_completion_tokens: 1500 } : { max_tokens: 1500 },
1284
- // json_object mode is an OpenAI-specific extension - Groq supports
1285
- // it too, but not every OpenAI-compatible server does, so only
1286
- // request it for providers confirmed to support it.
1287
- ...provider === "openai" || provider === "groq" ? { response_format: { type: "json_object" } } : {}
1288
- });
1567
+ }
1568
+ let response;
1569
+ try {
1570
+ response = await fetchWithTimeout(endpoint, { method: "POST", headers, body: JSON.stringify(body) }, timeoutMs);
1571
+ } catch (error) {
1572
+ 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);
1573
+ return { error: `${ai.label} request failed: ${reason}` };
1574
+ }
1575
+ if (!response.ok) {
1576
+ const detail = (await response.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 200);
1577
+ return { error: `${ai.label} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}` };
1578
+ }
1579
+ let data;
1580
+ try {
1581
+ data = await response.json();
1582
+ } catch {
1583
+ return { error: `${ai.label} returned a non-JSON response` };
1584
+ }
1585
+ const content = ai.anthropic ? data.content?.filter((b) => typeof b.text === "string").map((b) => b.text).join("") : data.choices?.[0]?.message?.content;
1586
+ if (!content) {
1587
+ return { error: `${ai.label} returned no content (a reasoning model may have used its whole token budget - try another --ai-model)` };
1588
+ }
1589
+ const parsed = extractJsonObject(content);
1590
+ const analysis = parsed ? normalizeAnalysis(parsed) : null;
1591
+ if (!analysis) {
1592
+ return { error: `${ai.label} (${ai.model}) did not return the requested JSON` };
1593
+ }
1594
+ return { analysis };
1595
+ }
1596
+
1597
+ // src/commands/audit.ts
1598
+ var { detectFramework: detectFramework2, getFrameworkDisplayName } = frameworks;
1599
+ var EXIT_CODES = {
1600
+ /** Audit completed and no --fail-on / --min-score gate tripped. */
1601
+ OK: 0,
1602
+ /** A --fail-on or --min-score gate tripped. */
1603
+ GATE_FAILED: 1,
1604
+ /** Bad invocation: missing/invalid --url or an invalid flag value. */
1605
+ USAGE: 2,
1606
+ /** No real audit: the site was unreachable, the URL answered 4xx/5xx, no page could be analyzed, or an internal error. */
1607
+ AUDIT_FAILED: 3
1608
+ };
1609
+ var FAIL_ON_LEVELS = ["error", "warning", "notice"];
1610
+ var SEVERITY_RANK = { critical: 4, error: 3, warning: 2, notice: 1, info: 1 };
1611
+ function evaluateGate(issues, score, failOn, minScore) {
1612
+ const failures = [];
1613
+ if (failOn) {
1614
+ const threshold = SEVERITY_RANK[failOn];
1615
+ const hits = issues.filter((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
1616
+ if (hits.length > 0) {
1617
+ const counts = /* @__PURE__ */ new Map();
1618
+ for (const i of hits) counts.set(i.severity, (counts.get(i.severity) || 0) + 1);
1619
+ const breakdown = [...counts.entries()].sort((a, b) => (SEVERITY_RANK[b[0]] ?? 0) - (SEVERITY_RANK[a[0]] ?? 0)).map(([severity, n]) => `${n} ${severity}`).join(", ");
1620
+ failures.push(`${hits.length} issue${hits.length === 1 ? "" : "s"} at or above --fail-on ${failOn} (${breakdown})`);
1289
1621
  }
1290
- const response = await fetch(apiUrl, { method: "POST", headers, body });
1622
+ }
1623
+ if (minScore !== void 0 && score < minScore) {
1624
+ failures.push(`score ${score} is below --min-score ${minScore}`);
1625
+ }
1626
+ return { failOn, minScore, passed: failures.length === 0, failures };
1627
+ }
1628
+ function parseGateOptions(options) {
1629
+ let failOn;
1630
+ let minScore;
1631
+ if (options.failOn !== void 0) {
1632
+ const level = String(options.failOn).trim().toLowerCase();
1633
+ if (!FAIL_ON_LEVELS.includes(level)) {
1634
+ return { error: `Invalid --fail-on "${options.failOn}" (use ${FAIL_ON_LEVELS.join(", ")})` };
1635
+ }
1636
+ failOn = level;
1637
+ }
1638
+ if (options.minScore !== void 0) {
1639
+ const raw = String(options.minScore).trim();
1640
+ const n = Number(raw);
1641
+ if (raw === "" || !Number.isFinite(n) || n < 0 || n > 100) {
1642
+ return { error: `Invalid --min-score "${options.minScore}" (use a number from 0 to 100)` };
1643
+ }
1644
+ minScore = n;
1645
+ }
1646
+ return { failOn, minScore };
1647
+ }
1648
+ async function fetchHtml(url, timeout = 1e4) {
1649
+ try {
1650
+ const controller = new AbortController();
1651
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1652
+ const response = await fetch(url, {
1653
+ signal: controller.signal,
1654
+ headers: {
1655
+ "User-Agent": "RankCLI-Bot/1.0 (+https://rankcli.dev/bot)",
1656
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1657
+ }
1658
+ });
1659
+ clearTimeout(timeoutId);
1291
1660
  if (!response.ok) {
1292
- console.error(chalk4.yellow(`AI analysis failed: ${response.status}`));
1293
1661
  return null;
1294
1662
  }
1295
- const data = await response.json();
1296
- const content = isAnthropic ? data.content?.[0]?.text : data.choices?.[0]?.message?.content;
1297
- if (!content) return null;
1298
- const jsonMatch = content.match(/\{[\s\S]*\}/);
1299
- if (!jsonMatch) return null;
1300
- return JSON.parse(jsonMatch[0]);
1301
- } catch (error) {
1302
- console.error(chalk4.yellow(`AI analysis error: ${error}`));
1663
+ return await response.text();
1664
+ } catch {
1303
1665
  return null;
1304
1666
  }
1305
1667
  }
@@ -1311,6 +1673,9 @@ function mergeIssues(issueArrays) {
1311
1673
  if (existing) {
1312
1674
  const urls = /* @__PURE__ */ new Set([...existing.affectedUrls || [], ...issue.affectedUrls || []]);
1313
1675
  existing.affectedUrls = Array.from(urls);
1676
+ if ((SEVERITY_RANK[issue.severity] ?? 0) > (SEVERITY_RANK[existing.severity] ?? 0)) {
1677
+ existing.severity = issue.severity;
1678
+ }
1314
1679
  } else {
1315
1680
  issueMap.set(issue.code, { ...issue });
1316
1681
  }
@@ -1356,9 +1721,9 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
1356
1721
  const data = await res.json();
1357
1722
  if (!res.ok) {
1358
1723
  if (data.error === "project_limit_reached") {
1359
- console.log(chalk4.yellow(`
1724
+ console.error(chalk4.yellow(`
1360
1725
  \u26A0\uFE0F Project limit reached (${data.count}/${data.limit})`));
1361
- console.log(chalk4.dim(` Upgrade at: ${WEB_APP_URL}/account`));
1726
+ console.error(chalk4.dim(` Upgrade at: ${WEB_APP_URL}/account`));
1362
1727
  } else {
1363
1728
  console.error(chalk4.dim(data.error || "Cloud sync failed"));
1364
1729
  }
@@ -1374,60 +1739,70 @@ async function syncToCloud(url, report, mergedIssues, pagesAudited, aiAnalysis,
1374
1739
  return null;
1375
1740
  }
1376
1741
  }
1742
+ function printUsage(loggedIn) {
1743
+ console.error("\n\u26A0\uFE0F Please provide a URL to audit:");
1744
+ console.error(" rankcli audit --url https://your-site.com");
1745
+ console.error('\nRun "rankcli audit --help" for every option, exit codes, and AI providers.');
1746
+ if (!loggedIn) {
1747
+ console.error(chalk4.dim("\nTip: Log in to sync results to the web dashboard:"));
1748
+ console.error(chalk4.dim(" rankcli login"));
1749
+ }
1750
+ console.error("");
1751
+ }
1377
1752
  async function audit(options) {
1378
1753
  const url = options.url;
1379
- const maxPages = parseInt(options.maxPages || "5", 10);
1380
- const enableAI = options.ai || false;
1381
- const detectedProvider = options.aiProvider || (process.env.OPENROUTER_API_KEY ? "openrouter" : process.env.GROQ_API_KEY ? "groq" : process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY ? "gemini" : process.env.ANTHROPIC_API_KEY ? "anthropic" : "openai");
1382
- const aiProvider = detectedProvider;
1383
- const providerEnvKey = {
1384
- openai: process.env.OPENAI_API_KEY,
1385
- anthropic: process.env.ANTHROPIC_API_KEY,
1386
- openrouter: process.env.OPENROUTER_API_KEY,
1387
- groq: process.env.GROQ_API_KEY,
1388
- gemini: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
1389
- };
1390
- const aiKey = options.aiKey || providerEnvKey[aiProvider] || "";
1391
- const loggedIn = isLoggedIn();
1392
- const { planId } = getUserInfo();
1393
- const tierId = planId && planId in TIER_LIMITS ? planId : loggedIn ? "free" : "free_anon";
1394
- const checksLimit = TIER_LIMITS[tierId].checks;
1395
- let premiumChecks;
1396
- if (checksLimit > TIER_LIMITS.free.checks) {
1397
- const passphrase = getDecryptPassphrase();
1398
- if (passphrase) {
1399
- try {
1400
- await initEngine(passphrase);
1401
- premiumChecks = await getPremiumChecks();
1402
- } catch (error) {
1403
- console.error(chalk4.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
1404
- }
1754
+ const json = options.output === "json";
1755
+ const fail = (code, message, extra = {}) => {
1756
+ if (json) {
1757
+ console.log(JSON.stringify({ url: url ?? null, error: message, exitCode: code, ...extra }, null, 2));
1758
+ } else {
1759
+ console.error(`
1760
+ \u274C ${message}
1761
+ `);
1405
1762
  }
1406
- }
1763
+ return code;
1764
+ };
1765
+ const gate = parseGateOptions(options);
1766
+ if (gate.error) return fail(EXIT_CODES.USAGE, gate.error);
1407
1767
  if (!url) {
1408
- console.log("\n\u26A0\uFE0F Please provide a URL to audit:");
1409
- console.log(" rankcli audit --url https://your-site.com");
1410
- console.log("\nOptions:");
1411
- console.log(" --max-pages <n> Max pages to crawl (default: 5)");
1412
- console.log(" --ai Enable AI analysis");
1413
- console.log(" --ai-provider <p> AI provider: openai, anthropic, openrouter, groq, or gemini (auto-detected from env if omitted)");
1414
- 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)");
1415
- console.log(" --output json Output as JSON");
1416
- if (!loggedIn) {
1417
- console.log(chalk4.dim("\nTip: Log in to sync results to the web dashboard:"));
1418
- console.log(chalk4.dim(" rankcli login"));
1419
- }
1420
- console.log("");
1421
- return;
1768
+ if (json) return fail(EXIT_CODES.USAGE, "No URL given - use --url https://your-site.com");
1769
+ printUsage(isLoggedIn());
1770
+ return EXIT_CODES.USAGE;
1422
1771
  }
1423
1772
  try {
1424
- new URL(url);
1773
+ const parsed = new URL(url);
1774
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)");
1425
1775
  } catch {
1426
- console.log("\n\u274C Invalid URL format. Please provide a valid URL.\n");
1427
- return;
1776
+ return fail(EXIT_CODES.USAGE, `Invalid URL "${url}" - use a full http(s) URL such as https://example.com`);
1428
1777
  }
1429
- const startTime = Date.now();
1778
+ if (options.ai && options.aiProvider && !AI_PROVIDERS[options.aiProvider.trim().toLowerCase()]) {
1779
+ return fail(EXIT_CODES.USAGE, `Unknown --ai-provider "${options.aiProvider}" (use ${AI_PROVIDER_IDS.join(", ")})`);
1780
+ }
1781
+ const maxPages = Math.max(1, parseInt(options.maxPages || "5", 10) || 5);
1430
1782
  try {
1783
+ const loggedIn = isLoggedIn();
1784
+ const keyPlan = await ensureApiKeyPlan();
1785
+ if (keyPlan?.status === "invalid") {
1786
+ console.error(chalk4.yellow(`\u26A0\uFE0F RankCLI API key rejected (${keyPlan.error}) - auditing on the Free tier.`));
1787
+ } else if (keyPlan?.status === "unavailable") {
1788
+ console.error(chalk4.dim(`(could not verify your RankCLI plan: ${keyPlan.error})`));
1789
+ }
1790
+ const { planId } = getUserInfo();
1791
+ const tierId = tierFromPlanId(planId) ?? (loggedIn ? "free" : "free_anon");
1792
+ const checksLimit = TIER_LIMITS[tierId].checks;
1793
+ let premiumChecks;
1794
+ {
1795
+ const passphrase = getDecryptPassphrase();
1796
+ if (passphrase) {
1797
+ try {
1798
+ await initEngine(passphrase);
1799
+ premiumChecks = await getPremiumChecks();
1800
+ } catch (error) {
1801
+ console.error(chalk4.dim(`(premium checks unavailable: ${error instanceof Error ? error.message : error})`));
1802
+ }
1803
+ }
1804
+ }
1805
+ const startTime = Date.now();
1431
1806
  const discoverySpinner = ora2("Discovering pages...").start();
1432
1807
  let pagesToAudit = [url];
1433
1808
  const homepageHtml = await fetchHtml(url, 1e4);
@@ -1447,6 +1822,7 @@ async function audit(options) {
1447
1822
  try {
1448
1823
  const report = await runFullAudit({
1449
1824
  url: pageUrl,
1825
+ auditRootUrl: url,
1450
1826
  maxPages: 1,
1451
1827
  checkBrokenLinks: options.checkLinks || false,
1452
1828
  // Bundled with --check-links: both mean "verify actual link
@@ -1483,40 +1859,68 @@ async function audit(options) {
1483
1859
  }
1484
1860
  if (!primaryReport) {
1485
1861
  auditSpinner.fail("Audit failed - could not analyze any pages");
1486
- return;
1862
+ return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed - could not analyze ${url}`);
1863
+ }
1864
+ const pageFailure = primaryReport.issues.find((i) => i.code === "FETCH_ERROR" || i.code === "HTTP_ERROR_STATUS");
1865
+ if (pageFailure) {
1866
+ auditSpinner.fail(`Could not audit ${url}: ${pageFailure.title}`);
1867
+ } else {
1868
+ auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
1487
1869
  }
1488
- auditSpinner.succeed(`Audited ${successCount}/${pagesToAudit.length} pages in ${parallelTime}ms`);
1489
1870
  const mergedIssues = mergeIssues(allIssues);
1871
+ const score = primaryReport.healthScore?.overall || 0;
1490
1872
  let aiAnalysis = null;
1491
- if (enableAI && aiKey) {
1492
- const aiSpinner = ora2(`Running AI analysis (${aiProvider})...`).start();
1493
- aiAnalysis = await runAIAnalysis(url, mergedIssues, aiProvider, aiKey);
1494
- if (aiAnalysis) {
1495
- aiSpinner.succeed("AI analysis complete");
1873
+ let aiError;
1874
+ if (options.ai && !pageFailure) {
1875
+ const resolution = await resolveAIProvider({
1876
+ aiProvider: options.aiProvider,
1877
+ aiKey: options.aiKey,
1878
+ aiModel: options.aiModel,
1879
+ aiBaseUrl: options.aiBaseUrl
1880
+ });
1881
+ if (!resolution.ok) {
1882
+ aiError = resolution.error;
1883
+ console.error(chalk4.yellow(`
1884
+ \u26A0\uFE0F ${resolution.error}`));
1885
+ for (const hint of resolution.hints) console.error(chalk4.dim(` ${hint}`));
1496
1886
  } else {
1497
- aiSpinner.warn("AI analysis failed or returned no results");
1887
+ const { ai } = resolution;
1888
+ if (ai.notice) console.error(chalk4.dim(ai.notice));
1889
+ const aiSpinner = ora2(`Running AI analysis (${ai.label}, ${ai.model})...`).start();
1890
+ const result = await runAIAnalysis(url, mergedIssues, ai);
1891
+ if ("analysis" in result) {
1892
+ aiAnalysis = result.analysis;
1893
+ aiSpinner.succeed("AI analysis complete");
1894
+ } else {
1895
+ aiError = result.error;
1896
+ aiSpinner.warn(`AI analysis failed: ${result.error}`);
1897
+ }
1498
1898
  }
1499
- } else if (enableAI && !aiKey) {
1500
- console.log(chalk4.yellow("\n\u26A0\uFE0F AI analysis requested but no API key provided"));
1501
- 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"));
1502
1899
  }
1900
+ const gateResult = evaluateGate(mergedIssues, score, gate.failOn, gate.minScore);
1901
+ const gated = gate.failOn !== void 0 || gate.minScore !== void 0;
1902
+ const exitCode = pageFailure ? EXIT_CODES.AUDIT_FAILED : gateResult.passed ? EXIT_CODES.OK : EXIT_CODES.GATE_FAILED;
1503
1903
  const totalTime = Date.now() - startTime;
1504
1904
  const frameworkName = detectedFramework.framework !== "unknown" ? getFrameworkDisplayName(detectedFramework.framework) : null;
1505
1905
  const actualChecksRun = (primaryReport.summary?.passed || 0) + (primaryReport.summary?.errors || 0) + (primaryReport.summary?.warnings || 0) || 200;
1506
- if (options.output === "json") {
1906
+ if (json) {
1507
1907
  let syncResult = null;
1508
1908
  if (loggedIn) {
1509
1909
  syncResult = await syncToCloud(url, primaryReport, mergedIssues, successCount, aiAnalysis, totalTime, actualChecksRun);
1510
1910
  }
1511
1911
  const jsonOutput = {
1512
1912
  url,
1513
- score: primaryReport.healthScore?.overall || 0,
1913
+ score,
1514
1914
  pagesAudited: successCount,
1515
1915
  totalTime,
1516
1916
  framework: frameworkName,
1517
1917
  issues: mergedIssues,
1518
1918
  healthScores: primaryReport.healthScore,
1519
1919
  aiAnalysis,
1920
+ ...aiError && { aiError },
1921
+ ...pageFailure && { error: pageFailure.title },
1922
+ ...gated && { gate: gateResult },
1923
+ exitCode,
1520
1924
  // Include cloud sync info if synced
1521
1925
  ...syncResult && {
1522
1926
  projectId: syncResult.projectId,
@@ -1525,11 +1929,13 @@ async function audit(options) {
1525
1929
  }
1526
1930
  };
1527
1931
  console.log(JSON.stringify(jsonOutput, null, 2));
1932
+ if (!gateResult.passed) {
1933
+ for (const failure of gateResult.failures) console.error(`Gate failed: ${failure}`);
1934
+ }
1528
1935
  } else {
1529
1936
  console.log("\n" + chalk4.bold("\u2550".repeat(60)));
1530
1937
  console.log(chalk4.bold.cyan(` SEO AUDIT REPORT: ${url}`));
1531
1938
  console.log(chalk4.bold("\u2550".repeat(60)));
1532
- const score = primaryReport.healthScore?.overall || 0;
1533
1939
  const scoreColor = score >= 80 ? chalk4.green : score >= 60 ? chalk4.yellow : chalk4.red;
1534
1940
  console.log(`
1535
1941
  ${chalk4.bold("Score:")} ${scoreColor.bold(score + "/100")}`);
@@ -1556,9 +1962,9 @@ async function audit(options) {
1556
1962
  console.log(` Local SEO: ${bar(hs.localSeo || 0)} ${hs.localSeo || 0}%`);
1557
1963
  console.log(` AI Readiness: ${bar(hs.aiReadiness || 0)} ${hs.aiReadiness || 0}%`);
1558
1964
  }
1559
- const errors = mergedIssues.filter((i) => i.severity === "error");
1965
+ const errors = mergedIssues.filter((i) => i.severity === "error" || i.severity === "critical");
1560
1966
  const warnings = mergedIssues.filter((i) => i.severity === "warning");
1561
- const notices = mergedIssues.filter((i) => i.severity === "notice");
1967
+ const notices = mergedIssues.filter((i) => i.severity === "notice" || i.severity === "info");
1562
1968
  console.log("\n" + chalk4.bold(" Issues by Severity:"));
1563
1969
  console.log(` ${chalk4.red("\u25CF")} Errors: ${errors.length}`);
1564
1970
  console.log(` ${chalk4.yellow("\u25CF")} Warnings: ${warnings.length}`);
@@ -1566,7 +1972,7 @@ async function audit(options) {
1566
1972
  console.log("\n" + chalk4.bold(" Top Issues:"));
1567
1973
  const topIssues = [...errors, ...warnings, ...notices].slice(0, 10);
1568
1974
  topIssues.forEach((issue, i) => {
1569
- const icon = issue.severity === "error" ? chalk4.red("\u2717") : issue.severity === "warning" ? chalk4.yellow("!") : chalk4.blue("i");
1975
+ const icon = issue.severity === "error" || issue.severity === "critical" ? chalk4.red("\u2717") : issue.severity === "warning" ? chalk4.yellow("!") : chalk4.blue("i");
1570
1976
  console.log(` ${i + 1}. ${icon} ${issue.title}`);
1571
1977
  if (issue.affectedUrls && issue.affectedUrls.length > 1) {
1572
1978
  console.log(chalk4.dim(` Affects ${issue.affectedUrls.length} pages`));
@@ -1587,21 +1993,15 @@ async function audit(options) {
1587
1993
  });
1588
1994
  }
1589
1995
  }
1590
- console.log("\n" + chalk4.bold("\u2550".repeat(60)));
1591
- const userInfo = getUserInfo();
1592
- const tier = userInfo.planName?.toLowerCase() || "free";
1593
- const isFreeTier = !loggedIn || tier === "free";
1594
- const isSoloOrBelow = isFreeTier || tier === "solo";
1595
- if (isFreeTier) {
1596
- const checksRun = mergedIssues.length > 0 ? loggedIn ? 100 : 50 : 0;
1597
- showUpsell("checks_limit", {
1598
- tier: loggedIn ? "free" : "free_anon",
1599
- current: checksRun
1600
- });
1601
- }
1602
- if (options.geo && isFreeTier) {
1603
- showUpsell("geo_limited", { tier: loggedIn ? "free" : "free_anon" });
1996
+ if (gated) {
1997
+ console.log("");
1998
+ if (gateResult.passed) {
1999
+ console.log(chalk4.green(` \u2713 CI gate passed${gate.failOn ? ` (--fail-on ${gate.failOn})` : ""}${gate.minScore !== void 0 ? ` (--min-score ${gate.minScore})` : ""}`));
2000
+ } else {
2001
+ for (const failure of gateResult.failures) console.log(chalk4.red(` \u2717 CI gate failed: ${failure}`));
2002
+ }
1604
2003
  }
2004
+ console.log("\n" + chalk4.bold("\u2550".repeat(60)));
1605
2005
  if (loggedIn) {
1606
2006
  const syncSpinner = ora2("Syncing to cloud...").start();
1607
2007
  const syncResult = await syncToCloud(url, primaryReport, mergedIssues, successCount, aiAnalysis, totalTime, actualChecksRun);
@@ -1617,13 +2017,46 @@ async function audit(options) {
1617
2017
  } else {
1618
2018
  showUpsell("free_audit_complete");
1619
2019
  }
2020
+ if (pageFailure) {
2021
+ console.error(chalk4.red(`
2022
+ \u274C ${pageFailure.title}: ${pageFailure.description}
2023
+ `));
2024
+ }
1620
2025
  }
2026
+ return exitCode;
1621
2027
  } catch (error) {
1622
- console.log(`
1623
- \u274C Audit failed: ${error instanceof Error ? error.message : "Unknown error"}
1624
- `);
2028
+ return fail(EXIT_CODES.AUDIT_FAILED, `Audit failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1625
2029
  }
1626
2030
  }
2031
+ var AUDIT_HELP_TEXT = `
2032
+ Exit codes:
2033
+ 0 audit completed; no --fail-on / --min-score gate tripped
2034
+ 1 a --fail-on or --min-score gate tripped
2035
+ 2 bad invocation (missing/invalid --url, invalid flag value)
2036
+ 3 no real audit: site unreachable, the URL answered 4xx/5xx, no page
2037
+ could be analyzed, or an internal error
2038
+
2039
+ CI gating (both opt-in; without them any completed audit exits 0):
2040
+ --fail-on error fail on any error
2041
+ --fail-on warning fail on any warning or error
2042
+ --fail-on notice fail on any issue at all
2043
+ --min-score 80 fail if the overall score is below 80
2044
+
2045
+ AI analysis (--ai) runs on your own provider. Hosted: ${Object.entries(AI_PROVIDERS).filter(([, p]) => !p.local).map(([id]) => id).join(", ")}.
2046
+ Picked automatically from the first key found in:
2047
+ ${AI_ENV_KEYS.join(", ")}
2048
+ (OpenRouter first). No key at all? A running local Ollama is used.
2049
+ Local: --ai-provider ollama | lmstudio | llamacpp | vllm (no key needed;
2050
+ the loaded model is used unless you pass --ai-model), or any
2051
+ OpenAI-compatible server: --ai-base-url http://host:port/v1 --ai-model <name>
2052
+
2053
+ Examples:
2054
+ rankcli audit --url https://example.com
2055
+ rankcli audit --url https://example.com --fail-on error --min-score 80
2056
+ rankcli audit --url https://example.com -o json > report.json
2057
+ rankcli audit --url https://example.com --ai --ai-provider deepseek
2058
+ rankcli audit --url https://example.com --ai --ai-provider ollama --ai-model qwen3:8b
2059
+ `;
1627
2060
 
1628
2061
  // src/commands/apply.ts
1629
2062
  import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
@@ -1687,14 +2120,6 @@ async function apply(options) {
1687
2120
  }
1688
2121
  console.log("\n\u{1F680} RankCLI - Apply Fixes");
1689
2122
  console.log("\u2550".repeat(50));
1690
- const loggedIn = isLoggedIn();
1691
- const userInfo = getUserInfo();
1692
- const tier = userInfo.planName?.toLowerCase() || "free";
1693
- const hasAutoFix = loggedIn && hasFeature(tier, "auto_fix");
1694
- if (!hasAutoFix) {
1695
- showUpsell("auto_fix_blocked");
1696
- return;
1697
- }
1698
2123
  console.log("\n\u{1F50D} Detecting framework...");
1699
2124
  const frameworkResult = await detectFramework3({ cwd });
1700
2125
  const framework = frameworkResult.success ? frameworkResult.data : { name: "Unknown", metaPattern: "html-head" };
@@ -1905,6 +2330,29 @@ ${index + 1}. ${icon} ${fix2.issue.message}`);
1905
2330
  }
1906
2331
  }
1907
2332
  async function fix(options) {
2333
+ const result = await runFix(options);
2334
+ if (options.output === "json" && !result.json) {
2335
+ result.json = JSON.stringify({
2336
+ url: result.url ?? options.url ?? null,
2337
+ success: result.success,
2338
+ ...result.error && { error: result.error },
2339
+ dryRun: !!result.dryRun,
2340
+ fixes: (result.fixes || []).map((f) => ({
2341
+ code: f.issue.code,
2342
+ message: f.issue.message,
2343
+ file: f.file,
2344
+ applied: !!f.applied,
2345
+ skipped: f.skipped,
2346
+ skipReason: f.skipReason
2347
+ })),
2348
+ appliedCount: result.appliedCount ?? 0,
2349
+ skippedCount: result.skippedCount ?? 0,
2350
+ ...result.durationMs !== void 0 && { durationMs: result.durationMs }
2351
+ }, null, 2);
2352
+ }
2353
+ return result;
2354
+ }
2355
+ async function runFix(options) {
1908
2356
  const startTime = Date.now();
1909
2357
  const cwd = options.cwd || process.cwd();
1910
2358
  const url = options.url;
@@ -1929,18 +2377,9 @@ async function fix(options) {
1929
2377
  return { success: false, error };
1930
2378
  }
1931
2379
  const loggedIn = isLoggedIn();
2380
+ if (loggedIn) await ensureApiKeyPlan();
1932
2381
  const userInfo = getUserInfo();
1933
- const tier = userInfo.planName?.toLowerCase() || "free";
1934
- const hasAutoFix = loggedIn && hasFeature(tier, "auto_fix");
1935
- if (!options.dryRun && !hasAutoFix) {
1936
- if (options.output !== "json") {
1937
- showUpsell("auto_fix_blocked");
1938
- }
1939
- return {
1940
- success: false,
1941
- error: "Auto-fix requires Solo tier or higher. Please upgrade at " + WEB_APP_URL + "/account"
1942
- };
1943
- }
2382
+ const tier = tierFromPlanId(userInfo.planId) ?? tierFromPlanId(userInfo.planName) ?? "free";
1944
2383
  if (options.output !== "json") {
1945
2384
  console.log("\n\u{1F527} RankCLI Fix - Agentic SEO Fixer");
1946
2385
  console.log("\u2550".repeat(50));
@@ -3284,7 +3723,7 @@ async function alerts(options) {
3284
3723
  if (options.list) {
3285
3724
  console.log("\u{1F4CB} Current alert configurations:\n");
3286
3725
  console.log(" (Alert configs are stored in your RankCLI account)");
3287
- console.log(" Run `rankcli auth login` first, then manage alerts at https://rankcli.dev/account/alerts\n");
3726
+ console.log(" Run `rankcli login` first, then manage alerts at https://rankcli.dev/account?tab=notifications\n");
3288
3727
  rl.close();
3289
3728
  return;
3290
3729
  }
@@ -3359,8 +3798,8 @@ async function alerts(options) {
3359
3798
  }
3360
3799
  console.log("");
3361
3800
  console.log("To save this configuration:");
3362
- console.log("1. Log in with `rankcli auth login`");
3363
- console.log("2. Add alert config at https://rankcli.dev/account/alerts");
3801
+ console.log("1. Log in with `rankcli login`");
3802
+ console.log("2. Add alert config at https://rankcli.dev/account?tab=notifications");
3364
3803
  console.log("");
3365
3804
  console.log("Or use the API:");
3366
3805
  console.log(` POST /api/alerts with webhook_url and settings`);
@@ -3419,17 +3858,21 @@ async function testWebhook(channel, webhookUrl) {
3419
3858
  }
3420
3859
 
3421
3860
  // src/version.ts
3422
- var VERSION = "0.0.29";
3861
+ var VERSION = "0.0.31";
3423
3862
 
3424
3863
  // src/index.ts
3425
3864
  var program = new Command();
3426
3865
  program.name("rankcli").description("RankCLI - Ship code, get ranked. SEO meets CI/CD.").version(VERSION);
3427
- 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(login);
3866
+ 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) => {
3867
+ process.exitCode = await login(options);
3868
+ });
3428
3869
  program.command("logout").description("Log out of your RankCLI account").action(logout);
3429
3870
  program.command("whoami").description("Show current logged in user").action(whoami);
3430
3871
  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);
3431
3872
  program.command("analyze").description("AI analyzes your codebase for SEO opportunities").option("-v, --verbose", "Show detailed output").action(analyze);
3432
- 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, openrouter, groq, or gemini (auto-detected from env if omitted)").option("--ai-key <key>", "API key (or set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY)").action(audit);
3873
+ 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) => {
3874
+ process.exitCode = await audit(options);
3875
+ });
3433
3876
  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);
3434
3877
  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) => {
3435
3878
  const result = await fix({
@@ -3510,7 +3953,10 @@ program.command("report").description("Generate SEO performance report").option(
3510
3953
  program.command("watch").description("Continuous monitoring mode").action(() => {
3511
3954
  console.log("Watch mode coming soon...");
3512
3955
  });
3513
- 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);
3956
+ 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);
3514
3957
  program.command("gsc-setup").description("Show Google Search Console API setup instructions").action(showGSCInstructions);
3515
3958
  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);
3516
- program.parse();
3959
+ program.parseAsync().catch((error) => {
3960
+ console.error(error instanceof Error ? error.message : String(error));
3961
+ process.exitCode = 1;
3962
+ });