@getvitops/cli 2.0.0 → 3.0.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/cli.mjs CHANGED
@@ -4,8 +4,10 @@ import { parseArgs } from "node:util";
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, parse, relative, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isSiteConfig, resolveInput, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
7
+ import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
8
8
  import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
9
+ import { SNAPSHOT_PATH, collectEntries, formatPlan, getAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot } from "@getvitops/utils/indexing";
10
+ import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$1, processMedia } from "@getvitops/utils/media";
9
11
 
10
12
  //#region src/agents.ts
11
13
  /**
@@ -28,6 +30,10 @@ const TOPICS = {
28
30
  path: "authoring.md",
29
31
  summary: "every design-system.json field, rendered from the JSON Schema"
30
32
  },
33
+ config: {
34
+ path: "config.md",
35
+ summary: "every field of the three-section config (designSystem / organization / site), rendered from the JSON Schema"
36
+ },
31
37
  formats: {
32
38
  path: "formats.md",
33
39
  summary: "tailwind vs css vs bricks: what's emitted vs what the platform provides"
@@ -530,6 +536,8 @@ function lintSource(files, v, format) {
530
536
  * vitops docs [topic] [--input <json>] [--all]
531
537
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
532
538
  * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
539
+ * vitops indexing [--input <site.json>] [--dry] [--all] [--check]
540
+ * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
533
541
  *
534
542
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
535
543
  * Every client brings their own consumer-editable design-system.json.
@@ -572,6 +580,8 @@ Usage:
572
580
  vitops lint [options] Report framework classes in your source that resolve to nothing
573
581
  vitops legal [options] Render legal documents from a site config
574
582
  vitops icons [options] Report which icons your source uses, and build the sprite
583
+ vitops indexing [options] Tell search engines about a deploy (sitemap + IndexNow)
584
+ vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
575
585
 
576
586
  Generate options:
577
587
  -i, --input <path> design-system.json or site config (default: ./design-system.json)
@@ -644,6 +654,56 @@ Legal options:
644
654
  Generated from your config — a starting point, not legal advice. Review before
645
655
  publishing, and make sure the config describes what your site actually does.
646
656
 
657
+ Indexing options:
658
+ -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
659
+ --site-env <env> Environment to notify for (default: production). An
660
+ environment whose robots policy says noindex is refused.
661
+ --sitemap <src> Sitemap URL or local path (default: from the config)
662
+ --urls <list> Comma-separated URLs to submit, skipping the diff
663
+ --all Submit every URL in the sitemap, not just what changed
664
+ --dry Print the plan and exit. Makes no requests.
665
+ --check Read-only: ask Google whether seo.indexing.priorityUrls
666
+ are indexed. Exits non-zero if one is not.
667
+ --new-key Print a fresh IndexNow key and exit
668
+ --write-key <dir> Write the IndexNow key file into <dir> (for stacks with
669
+ no Astro integration to do it — Bricks, WordPress)
670
+ --snapshot <path> Changed-URL state file (default: .vitops/sitemap-snapshot.json).
671
+ Persist it between runs (a CI cache) or every run submits
672
+ everything.
673
+
674
+ What this can and cannot do: Google exposes no "request indexing" API, and its
675
+ sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
676
+ through the Search Console API and verifies the result with --check. IndexNow
677
+ reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
678
+ Search Console needs a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
679
+ JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), added as an owner of the
680
+ property.
681
+
682
+ Media options:
683
+ --raw <dir> Directory of unprocessed video, walked recursively
684
+ (default: ./raw)
685
+ -o, --out <dir> Where the encoded outputs go (default: ./src/assets/processed).
686
+ Subdirectories under --raw are preserved.
687
+ --max-width <px> Cap the output width, keeping aspect ratio (default: 1920;
688
+ 0 disables scaling)
689
+ --crf <n> Quality on VP9's scale, 0-63, lower is better (default: 32).
690
+ The MP4 fallback uses the equivalent on H.264's scale.
691
+ --max-bitrate <r> Optional ceiling, e.g. 2M or 800k (default: none, which is
692
+ constant quality rather than constrained)
693
+ --audio Keep the audio track (default: dropped — the common case is
694
+ a muted autoplay loop)
695
+ --poster-time <s> Timestamp the poster frame is taken from (default: 0, which
696
+ is often black on a clip that fades in)
697
+ --outputs <list> Comma-separated: webm | mp4 | poster (default: all three)
698
+ --manifest <path> Cache file (default: .vitops/media-manifest.json)
699
+ --force Re-encode everything, ignoring the cache
700
+ --dry Print the plan and exit. Encodes nothing.
701
+
702
+ Needs ffmpeg on PATH — it is an external tool, not an npm dependency, and this
703
+ command fails rather than quietly skipping. Commit the outputs and the manifest:
704
+ ffmpeg output is not reproducible across versions, so re-encoding in CI churns
705
+ the diff on every toolchain bump. Use --force when you mean to re-encode.
706
+
647
707
  Common:
648
708
  -h, --help Show this help
649
709
  `;
@@ -748,10 +808,10 @@ function cmdValidate(argv) {
748
808
  } catch (err) {
749
809
  fail(`could not parse JSON: ${err.message}`);
750
810
  }
751
- if (isSiteConfig(raw)) {
811
+ if (isConfig(raw)) {
752
812
  let site;
753
813
  try {
754
- site = resolveSiteConfig(raw, values["site-env"]);
814
+ site = resolveConfig(raw, values["site-env"]);
755
815
  } catch (err) {
756
816
  console.error(`✖ ${path} is invalid as a site config:`);
757
817
  console.error(err.message.replace(/^Invalid site config:\n/, ""));
@@ -815,6 +875,99 @@ async function cmdFavicon(argv) {
815
875
  fail(err.message);
816
876
  }
817
877
  }
878
+ const OUTPUT_KINDS = new Set([
879
+ "webm",
880
+ "mp4",
881
+ "poster"
882
+ ]);
883
+ /**
884
+ * `vitops media` — encode a directory of raw video into web-ready outputs.
885
+ *
886
+ * Flags only, with no config file, following `favicon`: `site.favicon` exists in
887
+ * the schema and `cmdFavicon` still doesn't read it. The same reasoning applies
888
+ * more strongly here — `legal`, `icons` and `indexing` are anchored to a
889
+ * `Config` because what they emit *describes the site*, and an encoder setting
890
+ * describes a build step.
891
+ */
892
+ async function cmdMedia(argv) {
893
+ const { values } = parseArgs({
894
+ args: argv,
895
+ options: {
896
+ raw: {
897
+ type: "string",
898
+ default: "raw"
899
+ },
900
+ out: {
901
+ type: "string",
902
+ short: "o",
903
+ default: "src/assets/processed"
904
+ },
905
+ "max-width": { type: "string" },
906
+ crf: { type: "string" },
907
+ "max-bitrate": { type: "string" },
908
+ audio: {
909
+ type: "boolean",
910
+ default: false
911
+ },
912
+ "poster-time": { type: "string" },
913
+ outputs: { type: "string" },
914
+ manifest: {
915
+ type: "string",
916
+ default: MEDIA_MANIFEST_PATH
917
+ },
918
+ force: {
919
+ type: "boolean",
920
+ default: false
921
+ },
922
+ dry: {
923
+ type: "boolean",
924
+ default: false
925
+ }
926
+ },
927
+ allowPositionals: false
928
+ });
929
+ const num = (flag, raw) => {
930
+ if (raw === void 0) return void 0;
931
+ const n = Number(raw);
932
+ if (!Number.isFinite(n) || n < 0) fail(`media: --${flag} must be a non-negative number`);
933
+ return n;
934
+ };
935
+ let outputs;
936
+ if (values.outputs) {
937
+ outputs = values.outputs.split(",").map((s) => s.trim());
938
+ for (const kind of outputs) if (!OUTPUT_KINDS.has(kind)) fail(`media: unknown output "${kind}" (expected: webm | mp4 | poster)`);
939
+ }
940
+ const maxWidth = num("max-width", values["max-width"]);
941
+ const crf = num("crf", values.crf);
942
+ const posterTime = num("poster-time", values["poster-time"]);
943
+ const config = {
944
+ ...maxWidth !== void 0 ? { maxWidth } : {},
945
+ ...crf !== void 0 ? { crf } : {},
946
+ ...values["max-bitrate"] ? { maxBitrate: values["max-bitrate"] } : {},
947
+ ...values.audio ? { audio: true } : {},
948
+ ...posterTime !== void 0 ? { posterTime } : {},
949
+ ...outputs ? { outputs } : {}
950
+ };
951
+ try {
952
+ const result = await processMedia({
953
+ raw: values.raw,
954
+ out: values.out,
955
+ config,
956
+ manifest: values.manifest,
957
+ force: values.force,
958
+ dry: values.dry,
959
+ onProgress: (message) => console.error(` … ${message}`)
960
+ });
961
+ if (values.dry) {
962
+ console.log(formatPlan$1(result.plan));
963
+ return;
964
+ }
965
+ console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
966
+ for (const note of result.plan.notes) console.error(` ! ${note}`);
967
+ } catch (err) {
968
+ fail(err.message);
969
+ }
970
+ }
818
971
  /**
819
972
  * Read + parse + validate the design system at `path`, or exit with a message.
820
973
  *
@@ -824,7 +977,7 @@ async function cmdFavicon(argv) {
824
977
  * `company.json` doesn't have to maintain a second file for the tooling's sake.
825
978
  * `resolveInput` tells them apart by shape and resolves the theme.
826
979
  */
827
- function loadConfig(path, theme) {
980
+ function loadDesignSystem(path, theme) {
828
981
  if (!existsSync(path)) fail(`config not found: ${path}`);
829
982
  let raw;
830
983
  try {
@@ -852,11 +1005,11 @@ function loadConfig(path, theme) {
852
1005
  * Read + resolve a site config, or exit with a message.
853
1006
  *
854
1007
  * Accepts JSON, or a JS/TS module with a default export — deliberately not YAML,
855
- * which would mean a parser dependency for one command. `resolveSiteConfig` is
1008
+ * which would mean a parser dependency for one command. `resolveConfig` is
856
1009
  * documented as taking an already-parsed object precisely so the loader is the
857
1010
  * consumer's choice; anyone on YAML can convert it or export from a module.
858
1011
  */
859
- async function loadSiteConfig(path, siteEnv) {
1012
+ async function loadConfig(path, siteEnv) {
860
1013
  if (!existsSync(path)) fail(`site config not found: ${path}`);
861
1014
  let raw;
862
1015
  if (/\.(json)$/.test(path)) try {
@@ -873,7 +1026,7 @@ async function loadSiteConfig(path, siteEnv) {
873
1026
  if (raw == null) fail(`${path} has no default export`);
874
1027
  }
875
1028
  try {
876
- return resolveSiteConfig(raw, siteEnv);
1029
+ return resolveConfig(raw, siteEnv);
877
1030
  } catch (err) {
878
1031
  fail(err.message);
879
1032
  }
@@ -891,7 +1044,7 @@ async function loadSiteConfig(path, siteEnv) {
891
1044
  * sprite from it.
892
1045
  *
893
1046
  * A sibling of `legal` rather than a widening of `lint`: `lint` judges classes
894
- * against a design-system.json, whereas icons are anchored to a SiteConfig.
1047
+ * against a design-system.json, whereas icons are anchored to a Config.
895
1048
  *
896
1049
  * Exit codes carry the same distinction the Astro integration makes. A name the
897
1050
  * config DECLARES but that doesn't resolve is a config error and fails the
@@ -933,7 +1086,7 @@ async function cmdIcons(argv) {
933
1086
  allowPositionals: false
934
1087
  });
935
1088
  const sitePath = resolve(values.site);
936
- const icons = existsSync(sitePath) ? (await loadSiteConfig(sitePath, values["site-env"])).icons ?? {} : {};
1089
+ const icons = existsSync(sitePath) ? (await loadConfig(sitePath, values["site-env"])).site.icons ?? {} : {};
937
1090
  const ui = icons.ui ?? "fa7-solid";
938
1091
  const brand = icons.brand ?? "simple-icons";
939
1092
  const weight = icons.weight;
@@ -1042,9 +1195,9 @@ async function cmdLegal(argv) {
1042
1195
  if (!LEGAL_OUTPUTS.has(output)) fail(`unknown format "${output}" (expected: ${[...LEGAL_OUTPUTS].join(" | ")})`);
1043
1196
  const requested = values.doc ?? [];
1044
1197
  for (const d of requested) if (!LEGAL_DOCS.has(d)) fail(`unknown doc "${d}" (expected: ${[...LEGAL_DOCS].join(" | ")})`);
1045
- const site = await loadSiteConfig(resolve(values.input), values["site-env"]);
1198
+ const site = await loadConfig(resolve(values.input), values["site-env"]);
1046
1199
  const docs = requested.length ? requested : enabledDocs(site);
1047
- if (docs.length === 0) fail("no legal documents are enabled — set legal.privacyPolicy.enabled (or termsOfService / cookieConsent) in your site config, or name one with --doc");
1200
+ if (docs.length === 0) fail("no legal documents are enabled — set site.legal.privacyPolicy.enabled (or termsOfService / cookieConsent) in your config, or name one with --doc");
1048
1201
  const files = generateLegal(site, {
1049
1202
  docs,
1050
1203
  output
@@ -1059,6 +1212,210 @@ async function cmdLegal(argv) {
1059
1212
  console.log(`✓ legal → ${outDir} (${Object.keys(files).length} files)`);
1060
1213
  console.log(" These are generated from your config and are not legal advice — review them before publishing.");
1061
1214
  }
1215
+ /**
1216
+ * Adapt a `Config` to the indexing module's own option shape.
1217
+ *
1218
+ * A flat field map, as intended: `@getvitops/utils` cannot import from the
1219
+ * generator (the generator already depends on it), so `IndexingConfig` mirrors the
1220
+ * `seo.indexing` block rather than being it. The one piece of judgment here is the
1221
+ * origin — see below.
1222
+ */
1223
+ function toIndexingConfig(cfg, siteEnv) {
1224
+ const site = cfg.site;
1225
+ const indexing = site.seo?.indexing ?? {};
1226
+ const env = site.environments?.[siteEnv];
1227
+ const canonical = env?.url ?? site.domains?.canonical;
1228
+ return {
1229
+ ...canonical ? { canonical } : {},
1230
+ ...indexing.sitemapUrl ? { sitemapUrl: indexing.sitemapUrl } : {},
1231
+ ...indexing.indexNow ? { indexNow: indexing.indexNow } : {},
1232
+ ...indexing.searchConsole ? { searchConsole: indexing.searchConsole } : {},
1233
+ ...indexing.priorityUrls ? { priorityUrls: indexing.priorityUrls } : {},
1234
+ ...env?.robots ?? site.seo?.robots ? { robots: env?.robots ?? site.seo?.robots } : {}
1235
+ };
1236
+ }
1237
+ /**
1238
+ * The Search Console credential, from either supported source.
1239
+ *
1240
+ * Never from the config file: it is the one genuine secret in this command, and a
1241
+ * `site.json` is committed. Returns `undefined` rather than failing, so a site
1242
+ * running IndexNow alone doesn't need a GCP project at all.
1243
+ */
1244
+ function loadServiceAccount() {
1245
+ const inline = process.env.VITOPS_GSC_SERVICE_ACCOUNT;
1246
+ const path = process.env.GOOGLE_APPLICATION_CREDENTIALS;
1247
+ const raw = inline ?? (path && existsSync(path) ? readFileSync(path, "utf8") : void 0);
1248
+ if (!raw) return void 0;
1249
+ try {
1250
+ return parseServiceAccount(raw);
1251
+ } catch (err) {
1252
+ fail(`${inline ? "VITOPS_GSC_SERVICE_ACCOUNT" : `GOOGLE_APPLICATION_CREDENTIALS (${path})`}: ${err.message}`);
1253
+ }
1254
+ }
1255
+ /** Read a sitemap from a URL or a local path. */
1256
+ const readSitemap = async (source) => {
1257
+ if (/^https?:\/\//i.test(source)) {
1258
+ const res = await fetch(source);
1259
+ if (!res.ok) fail(`could not fetch ${source}: ${res.status}`);
1260
+ return res.text();
1261
+ }
1262
+ if (!existsSync(source)) fail(`sitemap not found: ${source}`);
1263
+ return readFileSync(source, "utf8");
1264
+ };
1265
+ /**
1266
+ * Tell search engines a deploy happened.
1267
+ *
1268
+ * Worth being precise about what this is, because the obvious expectation is
1269
+ * wrong: **Google has no API that requests indexing.** The Search Console button
1270
+ * is not exposed anywhere, URL Inspection is read-only, and the sitemap ping
1271
+ * endpoint was removed in 2023. So the Google half of this command resubmits the
1272
+ * sitemap and then *verifies* the outcome with `--check`; the immediate-push half
1273
+ * is IndexNow, which Google does not participate in.
1274
+ *
1275
+ * The Indexing API is deliberately absent — see `@getvitops/utils/indexing`'s
1276
+ * `gsc.ts`.
1277
+ */
1278
+ async function cmdIndexing(argv) {
1279
+ const { values } = parseArgs({
1280
+ args: argv,
1281
+ options: {
1282
+ input: {
1283
+ type: "string",
1284
+ short: "i",
1285
+ default: "site.json"
1286
+ },
1287
+ "site-env": {
1288
+ type: "string",
1289
+ default: "production"
1290
+ },
1291
+ sitemap: { type: "string" },
1292
+ urls: { type: "string" },
1293
+ all: {
1294
+ type: "boolean",
1295
+ default: false
1296
+ },
1297
+ dry: {
1298
+ type: "boolean",
1299
+ default: false
1300
+ },
1301
+ check: {
1302
+ type: "boolean",
1303
+ default: false
1304
+ },
1305
+ "new-key": {
1306
+ type: "boolean",
1307
+ default: false
1308
+ },
1309
+ "write-key": { type: "string" },
1310
+ snapshot: {
1311
+ type: "string",
1312
+ default: SNAPSHOT_PATH
1313
+ }
1314
+ },
1315
+ allowPositionals: false
1316
+ });
1317
+ if (values["new-key"]) {
1318
+ const key = newKey();
1319
+ console.log(key);
1320
+ console.log(`\n Add to your site config:\n "seo": { "indexing": { "indexNow": { "key": "${key}" } } }`);
1321
+ console.log(` Then serve it at /${key}.txt — \`vitops indexing --write-key public\`.`);
1322
+ return;
1323
+ }
1324
+ const config = toIndexingConfig(await loadConfig(resolve(values.input), values["site-env"]), values["site-env"]);
1325
+ if (values["write-key"] !== void 0) {
1326
+ const key = config.indexNow?.key;
1327
+ if (!key) fail("no site.seo.indexing.indexNow.key in the config — generate one with --new-key");
1328
+ const dir = resolve(values["write-key"]);
1329
+ mkdirSync(dir, { recursive: true });
1330
+ const file = join(dir, `${key}.txt`);
1331
+ writeFileSync(file, keyFileContents(key));
1332
+ console.log(`✓ IndexNow key file → ${file}`);
1333
+ console.log(` It must be served at ${resolveKeyLocation(config) ?? `/${key}.txt`}`);
1334
+ return;
1335
+ }
1336
+ const sitemapUrl = values.sitemap ?? resolveSitemapUrl(config);
1337
+ if (!sitemapUrl) fail("no sitemap to read — set seo.indexing.sitemapUrl or domains.canonical, or pass --sitemap");
1338
+ const explicitUrls = values.urls ? values.urls.split(",").map((u) => u.trim()).filter(Boolean) : void 0;
1339
+ const current = explicitUrls?.length && !values.check ? [] : (await collectEntries(sitemapUrl, readSitemap)).entries;
1340
+ const snapshotPath = resolve(values.snapshot);
1341
+ const p = plan({
1342
+ config: {
1343
+ ...config,
1344
+ sitemapUrl
1345
+ },
1346
+ current,
1347
+ previous: readSnapshot(snapshotPath),
1348
+ explicitUrls,
1349
+ all: values.all
1350
+ });
1351
+ console.log(formatPlan(p));
1352
+ if (p.blocked) return;
1353
+ if (values.dry) {
1354
+ console.log("\n(--dry: nothing was submitted)");
1355
+ return;
1356
+ }
1357
+ const account = loadServiceAccount();
1358
+ let failed = false;
1359
+ if (values.check) {
1360
+ if (!p.searchConsole.siteUrl) fail("--check needs seo.indexing.searchConsole.siteUrl (the Search Console property)");
1361
+ if (!account) fail("--check needs a Search Console service account in VITOPS_GSC_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS");
1362
+ if (p.check.length === 0) fail("--check needs seo.indexing.priorityUrls — the pages whose indexing matters");
1363
+ const token = await getAccessToken(account);
1364
+ console.log(`\nInspecting ${p.check.length} priority URL(s):`);
1365
+ for (const url of p.check) {
1366
+ const r = await inspectUrl(token, p.searchConsole.siteUrl, url);
1367
+ if (r.error) {
1368
+ console.log(` ? ${url} — ${r.error}`);
1369
+ failed = true;
1370
+ } else {
1371
+ console.log(` ${r.indexed ? "✓" : "✗"} ${url} — ${r.coverageState ?? r.verdict ?? "unknown"}`);
1372
+ if (!r.indexed) failed = true;
1373
+ }
1374
+ }
1375
+ if (failed) {
1376
+ console.error("\n✖ one or more priority URLs are not indexed");
1377
+ process.exit(1);
1378
+ }
1379
+ console.log("\n✓ every priority URL is indexed");
1380
+ return;
1381
+ }
1382
+ if (p.indexNow.enabled) {
1383
+ const { key, keyLocation } = p.indexNow;
1384
+ const check = await verifyKeyFile(keyLocation, key);
1385
+ if (!check.ok) {
1386
+ console.error(`✖ IndexNow key file check failed — ${check.reason}`);
1387
+ console.error(` Write it with: vitops indexing --write-key <public dir>`);
1388
+ failed = true;
1389
+ } else {
1390
+ let sent = 0;
1391
+ for (const batch of p.indexNow.batches) {
1392
+ const r = await submitBatch(p.indexNow, batch);
1393
+ if (!r.ok) {
1394
+ console.error(`✖ IndexNow: ${r.message ?? r.status}`);
1395
+ failed = true;
1396
+ break;
1397
+ }
1398
+ sent += r.urls;
1399
+ }
1400
+ if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
1401
+ }
1402
+ }
1403
+ if (p.searchConsole.enabled) if (!account) console.log("· Search Console: skipped — no VITOPS_GSC_SERVICE_ACCOUNT / GOOGLE_APPLICATION_CREDENTIALS");
1404
+ else {
1405
+ const r = await submitSitemap(await getAccessToken(account), p.searchConsole.siteUrl, sitemapUrl);
1406
+ if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1407
+ else {
1408
+ console.error(`✖ Search Console: ${r.message}`);
1409
+ failed = true;
1410
+ }
1411
+ }
1412
+ if (failed) process.exit(1);
1413
+ if (current.length > 0) {
1414
+ writeSnapshot(snapshotPath, toSnapshot(sitemapUrl, current, (/* @__PURE__ */ new Date()).toISOString()));
1415
+ console.log(` state → ${relative(process.cwd(), snapshotPath)}`);
1416
+ }
1417
+ if (p.check.length > 0) console.log(`\n Indexing is not instant. Run \`vitops indexing --check\` in a day or two to see what Google actually did.`);
1418
+ }
1062
1419
  function cliVersion() {
1063
1420
  try {
1064
1421
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
@@ -1096,7 +1453,7 @@ function cmdDocs(argv) {
1096
1453
  return;
1097
1454
  }
1098
1455
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
1099
- const docs = generateDocs(loadConfig(resolve(values.input), values.theme), generatorAssetsDir());
1456
+ const docs = generateDocs(loadDesignSystem(resolve(values.input), values.theme), generatorAssetsDir());
1100
1457
  const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
1101
1458
  process.stdout.write(paths.map((p) => docs[p]).join("\n"));
1102
1459
  }
@@ -1129,7 +1486,7 @@ function cmdLint(argv) {
1129
1486
  });
1130
1487
  const format = values.format;
1131
1488
  if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
1132
- const ds = loadConfig(resolve(values.input), values.theme);
1489
+ const ds = loadDesignSystem(resolve(values.input), values.theme);
1133
1490
  const srcDir = resolve(values.src);
1134
1491
  if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
1135
1492
  const palette = expandPalette(ds.colors.palette);
@@ -1182,7 +1539,7 @@ function cmdAgents(argv) {
1182
1539
  });
1183
1540
  const inputRel = values.input;
1184
1541
  let ds = null;
1185
- if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel), values.theme);
1542
+ if (existsSync(resolve(inputRel))) ds = loadDesignSystem(resolve(inputRel), values.theme);
1186
1543
  else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
1187
1544
  const legacyDocsDir = values["docs-dir"];
1188
1545
  let summary;
@@ -1284,7 +1641,9 @@ async function main() {
1284
1641
  case "lint": return cmdLint(rest);
1285
1642
  case "legal": return cmdLegal(rest);
1286
1643
  case "icons": return cmdIcons(rest);
1287
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons). Try: vitops --help`);
1644
+ case "indexing": return cmdIndexing(rest);
1645
+ case "media": return cmdMedia(rest);
1646
+ default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | indexing | media). Try: vitops --help`);
1288
1647
  }
1289
1648
  }
1290
1649
  main().catch((err) => fail(err.message));