indxel-cli 0.1.0 → 0.2.0

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/bin.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command5 } from "commander";
4
+ import { Command as Command6 } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { Command } from "commander";
8
8
  import chalk from "chalk";
9
9
  import ora from "ora";
10
- import { writeFile } from "fs/promises";
10
+ import { existsSync as existsSync2 } from "fs";
11
+ import { writeFile, mkdir, readFile as readFile2 } from "fs/promises";
11
12
  import { join as join2 } from "path";
12
13
 
13
14
  // src/detect.ts
@@ -175,7 +176,18 @@ export default function robots() {
175
176
  }
176
177
 
177
178
  // src/commands/init.ts
178
- var initCommand = new Command("init").description("Initialize indxel in your Next.js project").option("--cwd <path>", "Project directory", process.cwd()).option("--force", "Overwrite existing files", false).action(async (opts) => {
179
+ var PRE_PUSH_HOOK = `#!/bin/sh
180
+ # indxel SEO guard \u2014 blocks push if critical SEO errors are found
181
+ echo "\\033[36m[indxel]\\033[0m Running SEO check before push..."
182
+ npx indxel-cli check --ci
183
+ if [ $? -ne 0 ]; then
184
+ echo ""
185
+ echo "\\033[31m[indxel] Push blocked \u2014 fix SEO errors first.\\033[0m"
186
+ echo "\\033[2m Run 'npx indxel-cli check' for details.\\033[0m"
187
+ exit 1
188
+ fi
189
+ `;
190
+ var initCommand = new Command("init").description("Initialize indxel in your Next.js project").option("--cwd <path>", "Project directory", process.cwd()).option("--force", "Overwrite existing files", false).option("--hook", "Install git pre-push hook to block pushes on SEO errors", false).action(async (opts) => {
179
191
  const cwd = opts.cwd;
180
192
  const spinner = ora("Detecting project...").start();
181
193
  const project = await detectProject(cwd);
@@ -218,6 +230,31 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
218
230
  } else {
219
231
  console.log(chalk.dim(` - robots already exists (skip)`));
220
232
  }
233
+ const gitDir = join2(cwd, ".git");
234
+ const hasGit = existsSync2(gitDir);
235
+ if (opts.hook || opts.force) {
236
+ if (!hasGit) {
237
+ console.log(chalk.yellow(" \u26A0") + " No .git directory found \u2014 skip hook install");
238
+ } else {
239
+ const hooksDir = join2(gitDir, "hooks");
240
+ const hookPath = join2(hooksDir, "pre-push");
241
+ if (existsSync2(hookPath) && !opts.force) {
242
+ const existing = await readFile2(hookPath, "utf-8");
243
+ if (existing.includes("indxel")) {
244
+ console.log(chalk.dim(" - pre-push hook already installed (skip)"));
245
+ } else {
246
+ console.log(chalk.yellow(" \u26A0") + " pre-push hook already exists (use --force to overwrite)");
247
+ }
248
+ } else {
249
+ await mkdir(hooksDir, { recursive: true });
250
+ await writeFile(hookPath, PRE_PUSH_HOOK, { mode: 493 });
251
+ filesCreated.push(".git/hooks/pre-push");
252
+ console.log(chalk.green(" \u2713") + " Installed git pre-push hook");
253
+ }
254
+ }
255
+ } else if (hasGit) {
256
+ console.log(chalk.dim(" - Use --hook to install git pre-push guard"));
257
+ }
221
258
  console.log("");
222
259
  if (filesCreated.length > 0) {
223
260
  console.log(
@@ -231,6 +268,9 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
231
268
  console.log(chalk.dim(" Next steps:"));
232
269
  console.log(chalk.dim(` 1. Edit seo.config.${ext} with your site details`));
233
270
  console.log(chalk.dim(" 2. Run ") + chalk.bold("npx indxel check") + chalk.dim(" to audit your pages"));
271
+ if (!opts.hook && hasGit) {
272
+ console.log(chalk.dim(" 3. Run ") + chalk.bold("npx indxel init --hook") + chalk.dim(" to guard git pushes"));
273
+ }
234
274
  console.log("");
235
275
  });
236
276
 
@@ -241,7 +281,7 @@ import ora2 from "ora";
241
281
  import { validateMetadata } from "indxel";
242
282
 
243
283
  // src/scanner.ts
244
- import { readFile as readFile2 } from "fs/promises";
284
+ import { readFile as readFile3 } from "fs/promises";
245
285
  import { join as join3, dirname, sep } from "path";
246
286
  import { glob } from "glob";
247
287
  async function scanPages(projectRoot, appDir) {
@@ -253,7 +293,7 @@ async function scanPages(projectRoot, appDir) {
253
293
  const pages = [];
254
294
  for (const file of pageFiles) {
255
295
  const fullPath = join3(appDirFull, file);
256
- const content = await readFile2(fullPath, "utf-8");
296
+ const content = await readFile3(fullPath, "utf-8");
257
297
  const route = filePathToRoute(file);
258
298
  const page = {
259
299
  filePath: join3(appDir, file),
@@ -273,7 +313,7 @@ async function scanPages(projectRoot, appDir) {
273
313
  });
274
314
  for (const file of layoutFiles) {
275
315
  const fullPath = join3(appDirFull, file);
276
- const content = await readFile2(fullPath, "utf-8");
316
+ const content = await readFile3(fullPath, "utf-8");
277
317
  const route = filePathToRoute(file).replace(/\/layout$/, "") || "/";
278
318
  const hasMetadataExport = hasExport(content, "metadata") || hasExport(content, "generateMetadata");
279
319
  if (hasMetadataExport) {
@@ -551,15 +591,15 @@ function computeSummary(results) {
551
591
  }
552
592
 
553
593
  // src/store.ts
554
- import { existsSync as existsSync2 } from "fs";
555
- import { readFile as readFile3, writeFile as writeFile2, mkdir } from "fs/promises";
594
+ import { existsSync as existsSync3 } from "fs";
595
+ import { readFile as readFile4, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
556
596
  import { join as join4 } from "path";
557
597
  var STORE_DIR = ".indxel";
558
598
  var LAST_CHECK_FILE = "last-check.json";
559
599
  async function saveCheckResult(cwd, summary) {
560
600
  const storeDir = join4(cwd, STORE_DIR);
561
- if (!existsSync2(storeDir)) {
562
- await mkdir(storeDir, { recursive: true });
601
+ if (!existsSync3(storeDir)) {
602
+ await mkdir2(storeDir, { recursive: true });
563
603
  }
564
604
  const stored = {
565
605
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -586,11 +626,11 @@ async function saveCheckResult(cwd, summary) {
586
626
  }
587
627
  async function loadPreviousCheck(cwd) {
588
628
  const filePath = join4(cwd, STORE_DIR, LAST_CHECK_FILE);
589
- if (!existsSync2(filePath)) {
629
+ if (!existsSync3(filePath)) {
590
630
  return null;
591
631
  }
592
632
  try {
593
- const data = await readFile3(filePath, "utf-8");
633
+ const data = await readFile4(filePath, "utf-8");
594
634
  return JSON.parse(data);
595
635
  } catch {
596
636
  return null;
@@ -1118,14 +1158,248 @@ var keywordsCommand = new Command4("keywords").description("Research keyword opp
1118
1158
  }
1119
1159
  });
1120
1160
 
1161
+ // src/commands/index.ts
1162
+ import { Command as Command5 } from "commander";
1163
+ import chalk6 from "chalk";
1164
+ import ora5 from "ora";
1165
+ import { fetchSitemap as fetchSitemap2, fetchRobots as fetchRobots2 } from "indxel";
1166
+ async function checkPlan(apiKey) {
1167
+ try {
1168
+ const apiUrl = process.env.INDXEL_API_URL || "https://www.indxel.com";
1169
+ const res = await fetch(`${apiUrl}/api/cli/plan`, {
1170
+ headers: { Authorization: `Bearer ${apiKey}` },
1171
+ signal: AbortSignal.timeout(1e4)
1172
+ });
1173
+ if (!res.ok) return null;
1174
+ const data = await res.json();
1175
+ return data.plan ?? null;
1176
+ } catch {
1177
+ return null;
1178
+ }
1179
+ }
1180
+ var indexCommand = new Command5("index").description("Submit your pages to search engines and check indexation status").argument("<url>", "Site URL (e.g., https://yoursite.com)").option("--check", "Check which pages appear indexed (Pro+)", false).option("--indexnow-key <key>", "IndexNow API key for Bing/Yandex/DuckDuckGo submission (Pro+)").option("--api-key <key>", "Indxel API key (required for --check and --indexnow-key)").option("--json", "Output results as JSON", false).action(async (url, opts) => {
1181
+ const jsonOutput = opts.json;
1182
+ const needsPaid = opts.check || opts.indexnowKey;
1183
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
1184
+ url = `https://${url}`;
1185
+ }
1186
+ let baseUrl;
1187
+ try {
1188
+ baseUrl = new URL(url);
1189
+ } catch {
1190
+ console.error(chalk6.red(" Invalid URL."));
1191
+ process.exit(1);
1192
+ }
1193
+ const origin = baseUrl.origin;
1194
+ const host = baseUrl.hostname;
1195
+ if (!jsonOutput) {
1196
+ console.log("");
1197
+ console.log(chalk6.bold(" indxel index") + chalk6.dim(` \u2014 ${origin}`));
1198
+ console.log("");
1199
+ }
1200
+ if (needsPaid) {
1201
+ const apiKey = opts.apiKey || process.env.INDXEL_API_KEY;
1202
+ if (!apiKey) {
1203
+ if (!jsonOutput) {
1204
+ console.log(chalk6.red(" \u2717 --check and --indexnow-key require an API key (Pro plan)."));
1205
+ console.log(chalk6.dim(" Use --api-key or set INDXEL_API_KEY."));
1206
+ console.log(chalk6.dim(" Get your key at https://indxel.com/dashboard/settings"));
1207
+ console.log("");
1208
+ }
1209
+ process.exit(1);
1210
+ }
1211
+ const plan = await checkPlan(apiKey);
1212
+ if (!plan) {
1213
+ if (!jsonOutput) {
1214
+ console.log(chalk6.red(" \u2717 Invalid API key."));
1215
+ console.log("");
1216
+ }
1217
+ process.exit(1);
1218
+ }
1219
+ if (plan === "FREE") {
1220
+ if (!jsonOutput) {
1221
+ console.log(chalk6.red(" \u2717 IndexNow submission and indexation check require a Pro plan."));
1222
+ console.log(chalk6.dim(" Upgrade at https://indxel.com/pricing"));
1223
+ console.log("");
1224
+ }
1225
+ process.exit(1);
1226
+ }
1227
+ }
1228
+ const sitemapSpinner = jsonOutput ? null : ora5("Fetching sitemap...").start();
1229
+ const sitemapResult = await fetchSitemap2(origin);
1230
+ const sitemapUrl = `${origin}/sitemap.xml`;
1231
+ if (!sitemapResult.found || sitemapResult.urls.length === 0) {
1232
+ if (sitemapSpinner) sitemapSpinner.fail("No sitemap found");
1233
+ if (!jsonOutput) {
1234
+ console.log(chalk6.dim(" Create a sitemap first: ") + chalk6.bold("npx indxel init"));
1235
+ console.log("");
1236
+ }
1237
+ if (jsonOutput) {
1238
+ console.log(JSON.stringify({ error: "No sitemap found" }, null, 2));
1239
+ }
1240
+ process.exit(1);
1241
+ }
1242
+ if (sitemapSpinner) sitemapSpinner.succeed(`Found sitemap \u2014 ${sitemapResult.urls.length} URLs`);
1243
+ const robotsSpinner = jsonOutput ? null : ora5("Checking robots.txt...").start();
1244
+ const robotsResult = await fetchRobots2(origin);
1245
+ let sitemapInRobots = false;
1246
+ if (robotsResult.found) {
1247
+ sitemapInRobots = robotsResult.sitemapUrls.some(
1248
+ (s) => s.toLowerCase().includes("sitemap")
1249
+ );
1250
+ if (sitemapInRobots) {
1251
+ if (robotsSpinner) robotsSpinner.succeed("robots.txt references sitemap");
1252
+ } else {
1253
+ if (robotsSpinner) robotsSpinner.warn("robots.txt found but doesn't reference sitemap");
1254
+ if (!jsonOutput) {
1255
+ console.log(chalk6.dim(` Add this to your robots.txt:`));
1256
+ console.log(chalk6.dim(` Sitemap: ${sitemapUrl}`));
1257
+ }
1258
+ }
1259
+ } else {
1260
+ if (robotsSpinner) robotsSpinner.warn("No robots.txt found");
1261
+ if (!jsonOutput) {
1262
+ console.log(chalk6.dim(" Create one with: ") + chalk6.bold("npx indxel init"));
1263
+ }
1264
+ }
1265
+ if (!jsonOutput) console.log("");
1266
+ const indexNowKey = opts.indexnowKey || process.env.INDEXNOW_KEY;
1267
+ let indexNowResult = [];
1268
+ if (indexNowKey) {
1269
+ const urls = sitemapResult.urls.map((u) => u.loc);
1270
+ const indexNowSpinner = jsonOutput ? null : ora5("Submitting via IndexNow...").start();
1271
+ const indexNowEngines = [
1272
+ { name: "Bing/Yandex", endpoint: "https://api.indexnow.org/indexnow" }
1273
+ ];
1274
+ for (const engine of indexNowEngines) {
1275
+ try {
1276
+ const res = await fetch(engine.endpoint, {
1277
+ method: "POST",
1278
+ headers: { "Content-Type": "application/json" },
1279
+ body: JSON.stringify({
1280
+ host,
1281
+ key: indexNowKey,
1282
+ keyLocation: `${origin}/${indexNowKey}.txt`,
1283
+ urlList: urls.slice(0, 1e4)
1284
+ // IndexNow limit
1285
+ }),
1286
+ signal: AbortSignal.timeout(15e3)
1287
+ });
1288
+ if (res.ok || res.status === 200 || res.status === 202) {
1289
+ indexNowResult.push({ submitted: true, engine: engine.name, status: res.status });
1290
+ if (indexNowSpinner) indexNowSpinner.succeed(`IndexNow \u2014 ${urls.length} URLs submitted to ${engine.name}`);
1291
+ } else {
1292
+ indexNowResult.push({ submitted: false, engine: engine.name, status: res.status });
1293
+ if (indexNowSpinner) indexNowSpinner.warn(`IndexNow \u2014 ${engine.name} returned HTTP ${res.status}`);
1294
+ }
1295
+ } catch (err) {
1296
+ indexNowResult.push({ submitted: false, engine: engine.name });
1297
+ if (indexNowSpinner) indexNowSpinner.fail(`IndexNow \u2014 ${err instanceof Error ? err.message : "failed"}`);
1298
+ }
1299
+ }
1300
+ } else {
1301
+ if (!jsonOutput) {
1302
+ console.log(chalk6.bold(" IndexNow") + chalk6.dim(" (Bing, Yandex, DuckDuckGo)"));
1303
+ console.log(chalk6.dim(" Get instant indexing by setting up IndexNow:"));
1304
+ console.log(chalk6.dim(" 1. Generate a key at ") + chalk6.underline("https://www.bing.com/indexnow"));
1305
+ console.log(chalk6.dim(` 2. Host the key file at ${origin}/{key}.txt`));
1306
+ console.log(chalk6.dim(" 3. Run: ") + chalk6.bold(`npx indxel index ${host} --indexnow-key YOUR_KEY`));
1307
+ console.log("");
1308
+ }
1309
+ }
1310
+ if (!jsonOutput) {
1311
+ console.log(chalk6.bold(" Google Search Console"));
1312
+ console.log(chalk6.dim(" Google requires manual setup via Search Console:"));
1313
+ console.log(chalk6.dim(" 1. Go to ") + chalk6.underline("https://search.google.com/search-console"));
1314
+ console.log(chalk6.dim(` 2. Add & verify ${host}`));
1315
+ console.log(chalk6.dim(" 3. Submit your sitemap: Sitemaps > Add > sitemap.xml"));
1316
+ console.log("");
1317
+ }
1318
+ let indexationResults = null;
1319
+ if (opts.check) {
1320
+ const checkSpinner = jsonOutput ? null : ora5("Checking indexation status...").start();
1321
+ indexationResults = [];
1322
+ const urls = sitemapResult.urls.map((u) => u.loc);
1323
+ let indexedCount = 0;
1324
+ for (let i = 0; i < urls.length; i++) {
1325
+ const pageUrl = urls[i];
1326
+ if (checkSpinner) {
1327
+ checkSpinner.text = `Checking indexation... ${i + 1}/${urls.length}`;
1328
+ }
1329
+ try {
1330
+ const cacheUrl = `https://webcache.googleusercontent.com/search?q=cache:${encodeURIComponent(pageUrl)}`;
1331
+ const res = await fetch(cacheUrl, {
1332
+ method: "HEAD",
1333
+ signal: AbortSignal.timeout(5e3),
1334
+ redirect: "manual",
1335
+ headers: {
1336
+ "User-Agent": "Mozilla/5.0 (compatible; Indxel/0.1; +https://indxel.com)"
1337
+ }
1338
+ });
1339
+ const indexed = res.status === 200 || res.status === 301 || res.status === 302;
1340
+ indexationResults.push({ url: pageUrl, indexed });
1341
+ if (indexed) indexedCount++;
1342
+ } catch {
1343
+ indexationResults.push({ url: pageUrl, indexed: false });
1344
+ }
1345
+ await new Promise((r) => setTimeout(r, 300));
1346
+ }
1347
+ if (checkSpinner) {
1348
+ checkSpinner.succeed(`Indexation: ${indexedCount}/${urls.length} pages found in Google cache`);
1349
+ }
1350
+ if (!jsonOutput) {
1351
+ console.log("");
1352
+ const notIndexed = indexationResults.filter((r) => !r.indexed);
1353
+ if (notIndexed.length > 0) {
1354
+ console.log(chalk6.bold(` Not indexed (${notIndexed.length})`));
1355
+ for (const r of notIndexed.slice(0, 20)) {
1356
+ console.log(chalk6.red(" \u2717 ") + r.url);
1357
+ }
1358
+ if (notIndexed.length > 20) console.log(chalk6.dim(` ... and ${notIndexed.length - 20} more`));
1359
+ console.log("");
1360
+ }
1361
+ if (notIndexed.length === 0) {
1362
+ console.log(chalk6.green(" \u2713 All pages appear indexed"));
1363
+ console.log("");
1364
+ }
1365
+ }
1366
+ }
1367
+ if (jsonOutput) {
1368
+ console.log(JSON.stringify({
1369
+ sitemap: { url: sitemapUrl, urls: sitemapResult.urls.length },
1370
+ robotsTxt: { found: robotsResult.found, referencesSitemap: sitemapInRobots },
1371
+ indexNow: indexNowResult.length > 0 ? indexNowResult : null,
1372
+ indexation: indexationResults
1373
+ }, null, 2));
1374
+ } else {
1375
+ console.log(chalk6.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
1376
+ console.log("");
1377
+ console.log(` Sitemap: ${sitemapResult.urls.length} URLs`);
1378
+ console.log(` robots.txt: ${robotsResult.found ? sitemapInRobots ? chalk6.green("\u2713 references sitemap") : chalk6.yellow("\u26A0 missing sitemap ref") : chalk6.red("\u2717 not found")}`);
1379
+ if (indexNowResult.length > 0) {
1380
+ for (const r of indexNowResult) {
1381
+ console.log(` IndexNow: ${r.submitted ? chalk6.green("\u2713 submitted") : chalk6.red("\u2717 failed")} (${r.engine})`);
1382
+ }
1383
+ } else {
1384
+ console.log(` IndexNow: ${chalk6.dim("not configured (use --indexnow-key)")}`);
1385
+ }
1386
+ if (indexationResults) {
1387
+ const indexedCount = indexationResults.filter((r) => r.indexed).length;
1388
+ console.log(` Google cache: ${indexedCount}/${indexationResults.length} indexed`);
1389
+ }
1390
+ console.log("");
1391
+ }
1392
+ });
1393
+
1121
1394
  // src/index.ts
1122
1395
  function createProgram() {
1123
- const program2 = new Command5();
1396
+ const program2 = new Command6();
1124
1397
  program2.name("indxel").description("Infrastructure SEO developer-first. ESLint pour le SEO.").version("0.1.0");
1125
1398
  program2.addCommand(initCommand);
1126
1399
  program2.addCommand(checkCommand);
1127
1400
  program2.addCommand(crawlCommand);
1128
1401
  program2.addCommand(keywordsCommand);
1402
+ program2.addCommand(indexCommand);
1129
1403
  return program2;
1130
1404
  }
1131
1405