@witchpot/gmc 0.8.0 → 0.10.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/src/requests.js CHANGED
@@ -387,6 +387,29 @@ export function buildRequest(command, options, positional) {
387
387
  };
388
388
  }
389
389
 
390
+ // NAK-804. A different route shape from every other `games` sub-path above:
391
+ // there is exactly one game namespace behind it, keyed on the plain numeric
392
+ // appid, so there is no --source flag to select and no `assertSource` call.
393
+ // The endpoint takes no query parameters at all (the review-history
394
+ // sibling's shape), so `assertSupportedOptions` closes the same gap that
395
+ // one documents: an unsupported flag would otherwise be silently dropped
396
+ // and answer with exit 0 as if the narrowing had been applied.
397
+ if (matches(command, ["games", "ai-disclosure"])) {
398
+ assertSupportedOptions(options, {
399
+ command: "gmc games ai-disclosure",
400
+ allowed: []
401
+ });
402
+ const appid = assertAppid(positional[0], "gmc games ai-disclosure <appid> --json");
403
+ assertNoExtraPositionals(positional, {
404
+ expected: 1,
405
+ usage: "gmc games ai-disclosure <appid> --json"
406
+ });
407
+ return {
408
+ path: `/api/v1/games/${encodeURIComponent(String(appid))}/ai-disclosure`,
409
+ query: {}
410
+ };
411
+ }
412
+
390
413
  if (matches(command, ["reviews", "patterns"])) {
391
414
  assertSource(options.source);
392
415
  const isCompare = positional[0] === "compare";
@@ -627,6 +650,137 @@ export function buildRequest(command, options, positional) {
627
650
  };
628
651
  }
629
652
 
653
+ // NAK-715. Thin pass-throughs to the Roblox read endpoints, on the same rule
654
+ // the tag-trends branch above documents: the CLI forwards the flags it was
655
+ // given and the SERVER owns the contract — the per-endpoint allowlists, the
656
+ // enum members, the limit ceilings, the window bounds and the cursor
657
+ // binding. `assertSupportedOptions` still runs for the narrower thing it is
658
+ // for: a flag the endpoint has no parameter for would otherwise be dropped
659
+ // silently and answer the unnarrowed question with exit 0.
660
+ //
661
+ // A Roblox universe id is NOT a Steam appid and is never sent as a number.
662
+ // It travels as the decimal string the user typed, because these ids exceed
663
+ // the range a JS number represents exactly.
664
+ if (matches(command, ["roblox", "charts"])) {
665
+ assertSupportedOptions(options, {
666
+ command: "gmc roblox charts",
667
+ allowed: ["country", "device", "sort", "as-of", "limit", "offset"]
668
+ });
669
+ assertNoExtraPositionals(positional, {
670
+ expected: 0,
671
+ usage: "gmc roblox charts --country all --sort top-playing-now --json"
672
+ });
673
+ return {
674
+ path: "/api/v1/roblox/charts",
675
+ // Every value is omitted when unset so the server owns each default and
676
+ // no default can drift into a copy kept here.
677
+ query: pickQuery(options, {
678
+ country: "country",
679
+ device: "device",
680
+ sort: "sort",
681
+ "as-of": "as_of",
682
+ limit: "limit",
683
+ offset: "offset"
684
+ })
685
+ };
686
+ }
687
+
688
+ if (matches(command, ["roblox", "experiences"])) {
689
+ assertSupportedOptions(options, {
690
+ command: "gmc roblox experiences",
691
+ allowed: [
692
+ "country",
693
+ "device",
694
+ "sort",
695
+ "as-of",
696
+ "eligibility-days",
697
+ "min-player-count",
698
+ "max-rank",
699
+ "genre",
700
+ "name",
701
+ "creator-type",
702
+ "detail",
703
+ "order-by",
704
+ "order",
705
+ "limit",
706
+ "cursor"
707
+ ]
708
+ });
709
+ assertNoExtraPositionals(positional, {
710
+ expected: 0,
711
+ usage: "gmc roblox experiences --country all --limit 50 --json"
712
+ });
713
+ return {
714
+ path: "/api/v1/roblox/experiences",
715
+ query: pickQuery(options, {
716
+ country: "country",
717
+ device: "device",
718
+ sort: "sort",
719
+ "as-of": "as_of",
720
+ "eligibility-days": "eligibility_days",
721
+ "min-player-count": "min_player_count",
722
+ "max-rank": "max_rank",
723
+ genre: "genre",
724
+ name: "name",
725
+ "creator-type": "creator_type",
726
+ detail: "detail",
727
+ "order-by": "order_by",
728
+ order: "order",
729
+ limit: "limit",
730
+ cursor: "cursor"
731
+ })
732
+ };
733
+ }
734
+
735
+ if (matches(command, ["roblox", "experience"])) {
736
+ assertSupportedOptions(options, { command: "gmc roblox experience", allowed: ["as-of"] });
737
+ const universeId = assertUniverseId(
738
+ positional[0],
739
+ "gmc roblox experience <universeId> --json"
740
+ );
741
+ assertNoExtraPositionals(positional, {
742
+ expected: 1,
743
+ usage: "gmc roblox experience <universeId> --json"
744
+ });
745
+ return {
746
+ path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}`,
747
+ query: pickQuery(options, { "as-of": "as_of" })
748
+ };
749
+ }
750
+
751
+ if (matches(command, ["roblox", "history"])) {
752
+ assertSupportedOptions(options, {
753
+ command: "gmc roblox history",
754
+ allowed: ["granularity", "from", "to", "as-of"]
755
+ });
756
+ const universeId = assertUniverseId(
757
+ positional[0],
758
+ "gmc roblox history <universeId> --granularity daily --json"
759
+ );
760
+ assertNoExtraPositionals(positional, {
761
+ expected: 1,
762
+ usage: "gmc roblox history <universeId> --granularity daily --json"
763
+ });
764
+ return {
765
+ path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}/history`,
766
+ query: pickQuery(options, {
767
+ granularity: "granularity",
768
+ from: "from",
769
+ to: "to",
770
+ "as-of": "as_of"
771
+ })
772
+ };
773
+ }
774
+
775
+ if (matches(command, ["roblox", "coverage"])) {
776
+ assertSupportedOptions(options, { command: "gmc roblox coverage", allowed: ["as-of"] });
777
+ assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox coverage --json" });
778
+ return {
779
+ path: "/api/v1/roblox/coverage",
780
+ query: pickQuery(options, { "as-of": "as_of" })
781
+ };
782
+ }
783
+
630
784
  return null;
631
785
  }
632
786
 
@@ -749,6 +903,13 @@ function buildOptionalGameCountRequest(options) {
749
903
  // server would have rejected.
750
904
  const AI_DISCLOSURE_VALUES = ["disclosed", "absent", "unconfirmed"];
751
905
 
906
+ // Mirrors RELEASE_STATUS_FILTER_VALUES in the app's games repository module
907
+ // (NAK-803), which the market RPCs re-check against the same literal triple.
908
+ // Kept as a local literal for the same reason as AI_DISCLOSURE_VALUES above:
909
+ // the CLI package cannot import server code, and the server remains the
910
+ // enforcing authority.
911
+ const RELEASE_STATUS_VALUES = ["released", "upcoming", "tba"];
912
+
752
913
  function buildGameCountQuery(options) {
753
914
  const query = pickQuery(options, {
754
915
  query: "q",
@@ -834,6 +995,26 @@ function buildGameCountQuery(options) {
834
995
  AI_DISCLOSURE_VALUES
835
996
  );
836
997
  }
998
+ // NAK-803: --release-status is a FILTER, so it lives in the shared
999
+ // count-query builder and reaches games search, count and aggregate alike.
1000
+ // `releaseStatus` was already in the REST contract but had no CLI mapping,
1001
+ // which forced the CLI form of a "released games only" question to lean on
1002
+ // the release window alone (see docs/cli-use-cases.md section 67). Like
1003
+ // --ai-disclosure, the three values are a closed set the server enforces
1004
+ // case-sensitively, so a mistyped value is a usage error here rather than a
1005
+ // silently-unfiltered, still-charged request.
1006
+ //
1007
+ // Note this filter is an exact match layered ON TOP of the unconditional
1008
+ // stale-upcoming guard (NAK-78): a title still marked `upcoming` past its
1009
+ // provisional release date is dropped from every query regardless of this
1010
+ // flag, so `--release-status upcoming` never resurrects one.
1011
+ if (options["release-status"] != null) {
1012
+ query.releaseStatus = assertEnumQueryOption(
1013
+ options["release-status"],
1014
+ "--release-status",
1015
+ RELEASE_STATUS_VALUES
1016
+ );
1017
+ }
837
1018
  return query;
838
1019
  }
839
1020
 
@@ -1168,6 +1349,59 @@ function assertExternalId(value, usage) {
1168
1349
  }
1169
1350
  }
1170
1351
 
1352
+ /**
1353
+ * NAK-804. Validates the appid positional for `gmc games ai-disclosure`,
1354
+ * mirroring the strict path-parameter check the app applies to every
1355
+ * `/api/v1/.../games/{appid}` route: a plain positive decimal integer, and
1356
+ * nothing else. The lexical test is deliberately `/^\d+$/` rather than
1357
+ * `Number(value)`, the same reasoning `assertLanguageTopicMinReviews` above
1358
+ * documents for `--min-reviews` -- `Number` also accepts the exponent, hex
1359
+ * and decimal-point spellings ("7e2", "0x2da", "730.0") that the app's
1360
+ * validator rejects outright, so letting those through here would mean a
1361
+ * value the API answers 400 for silently reaches the wire as if it were
1362
+ * fine. This check is advisory; the server remains the enforcing authority.
1363
+ */
1364
+ function assertAppid(value, usage) {
1365
+ const raw = value == null ? "" : String(value);
1366
+ const appid = DECIMAL_INTEGER_RE.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
1367
+ if (!Number.isSafeInteger(appid) || appid <= 0) {
1368
+ writeJsonError({
1369
+ code: "CLI_USAGE_ERROR",
1370
+ message: `Missing or invalid appid (must be a positive integer). Usage: ${usage}`
1371
+ });
1372
+ process.exit(EXIT_USAGE);
1373
+ }
1374
+ return appid;
1375
+ }
1376
+
1377
+ /**
1378
+ * NAK-715. Validates the universeId positional for the Roblox per-experience
1379
+ * commands, mirroring the path-parameter check the app applies.
1380
+ *
1381
+ * It returns a STRING and never a number, which is the point: a Roblox
1382
+ * universe id is a 64-bit value, and `Number.parseInt` would round anything
1383
+ * above 2^53 into a different experience's id. The lexical test is
1384
+ * `/^[1-9][0-9]*$/` for the same reason `assertAppid` above uses a lexical
1385
+ * test — `Number` also accepts the exponent, hex and decimal-point spellings
1386
+ * the app's validator rejects, and leading zeros would make one experience
1387
+ * reachable under two spellings. This check is advisory; the server remains
1388
+ * the enforcing authority.
1389
+ *
1390
+ * A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
1391
+ * and this value never reaches a Steam route.
1392
+ */
1393
+ function assertUniverseId(value, usage) {
1394
+ const raw = value == null ? "" : String(value).trim();
1395
+ if (!/^[1-9][0-9]{0,18}$/.test(raw)) {
1396
+ writeJsonError({
1397
+ code: "CLI_USAGE_ERROR",
1398
+ message: `Missing or invalid universeId (must be a positive decimal Roblox universe id, not a Steam appid). Usage: ${usage}`
1399
+ });
1400
+ process.exit(EXIT_USAGE);
1401
+ }
1402
+ return raw;
1403
+ }
1404
+
1171
1405
  function assertResolveQuery(value) {
1172
1406
  if (value == null || String(value).trim() === "") {
1173
1407
  writeJsonError({
@@ -4,6 +4,7 @@ import test from "node:test";
4
4
  import { parseArgs } from "./args.js";
5
5
  import { buildRequest } from "./requests.js";
6
6
  import { printHelp } from "./help.js";
7
+ import { PROVISIONAL_BETA_COMMANDS } from "./constants.js";
7
8
 
8
9
  function buildRequestFromArgv(argv) {
9
10
  const { command, options, positional } = parseArgs(argv);
@@ -402,6 +403,117 @@ test("games more-like-this forwards an explicit country, normalized", () => {
402
403
  assert.deepEqual(request.query, { country: "jp" });
403
404
  });
404
405
 
406
+ // --- gmc games ai-disclosure (NAK-804) ------------------------------------
407
+ //
408
+ // A different route shape from every other `games` sub-path tested above:
409
+ // there is no --source flag (the endpoint is keyed on the plain numeric
410
+ // appid, not a --source-qualified externalId) and no query parameters at
411
+ // all, the review-history sibling's shape.
412
+
413
+ test("games ai-disclosure targets the canonical route", () => {
414
+ const request = buildRequestFromArgv(["games", "ai-disclosure", "730", "--json"]);
415
+
416
+ assert.equal(request.path, "/api/v1/games/730/ai-disclosure");
417
+ assert.deepEqual(request.query, {});
418
+ });
419
+
420
+ test("games ai-disclosure rejects a missing appid", () => {
421
+ const { output, exitCode } = captureUsageFailure(() =>
422
+ buildRequestFromArgv(["games", "ai-disclosure", "--json"])
423
+ );
424
+
425
+ assert.equal(exitCode, 2);
426
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
427
+ assert.match(output.error.message, /gmc games ai-disclosure <appid> --json/);
428
+ });
429
+
430
+ /**
431
+ * The appid is a plain positive decimal integer, matching the strict
432
+ * path-parameter check the app applies to every `/api/v1/.../games/{appid}`
433
+ * route. Each rejected spelling is asserted individually: a string like
434
+ * "7e2" or "0x2da" round-trips through `Number()` into a valid-looking
435
+ * integer, so a looser check here would let a value through that the API
436
+ * answers 400 for.
437
+ */
438
+ test("games ai-disclosure rejects non-numeric and malformed appid spellings", () => {
439
+ for (const badAppid of ["not-an-appid", "7e2", "0x2da", "730.5", "-730", "0", " 730", "730 "]) {
440
+ const { output, exitCode } = captureUsageFailure(() =>
441
+ buildRequestFromArgv(["games", "ai-disclosure", badAppid, "--json"])
442
+ );
443
+
444
+ assert.equal(exitCode, 2, `expected "${badAppid}" to be rejected`);
445
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
446
+ assert.match(output.error.message, /Missing or invalid appid/);
447
+ }
448
+ });
449
+
450
+ test("games ai-disclosure rejects request-shaping flags the endpoint does not have", () => {
451
+ for (const [flag, value] of [
452
+ ["--source", "steam"],
453
+ ["--locale", "ja"],
454
+ ["--limit", "50"]
455
+ ]) {
456
+ const { output, exitCode } = captureUsageFailure(() =>
457
+ buildRequestFromArgv(["games", "ai-disclosure", "730", flag, value, "--json"])
458
+ );
459
+
460
+ assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
461
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
462
+ assert.match(
463
+ output.error.message,
464
+ new RegExp(`\\${flag} is not supported by gmc games ai-disclosure`)
465
+ );
466
+ }
467
+ });
468
+
469
+ /**
470
+ * Output shaping never reaches the wire, so it must not trip the allowlist.
471
+ */
472
+ test("games ai-disclosure still accepts the global output flags", () => {
473
+ const request = buildRequestFromArgv(["games", "ai-disclosure", "730", "--fields", "state", "--json"]);
474
+
475
+ assert.equal(request.path, "/api/v1/games/730/ai-disclosure");
476
+ assert.deepEqual(request.query, {});
477
+ });
478
+
479
+ test("games ai-disclosure rejects an extra positional argument", () => {
480
+ const { output, exitCode } = captureUsageFailure(() =>
481
+ buildRequestFromArgv(["games", "ai-disclosure", "730", "440", "--json"])
482
+ );
483
+
484
+ assert.equal(exitCode, 2);
485
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
486
+ assert.match(output.error.message, /Unexpected extra argument "440"/);
487
+ });
488
+
489
+ test("help documents gmc games ai-disclosure (NAK-804)", () => {
490
+ const chunks = [];
491
+ const original = process.stdout.write;
492
+ process.stdout.write = (chunk) => {
493
+ chunks.push(String(chunk));
494
+ return true;
495
+ };
496
+ try {
497
+ printHelp();
498
+ } finally {
499
+ process.stdout.write = original;
500
+ }
501
+ const help = chunks.join("");
502
+
503
+ assert.ok(help.includes("gmc games ai-disclosure <appid> --json"));
504
+ assert.ok(help.includes("never that the title\n uses no generative AI"));
505
+ });
506
+
507
+ test("games ai-disclosure is listed as Provisional (NAK-804)", () => {
508
+ // CHANGELOG, api.md and cli-use-cases.md all state that this command is
509
+ // Provisional in the beta command set. The help-output substring check
510
+ // above cannot pin this: "gmc games ai-disclosure <appid> --json" also
511
+ // appears verbatim in the Usage section on its own, so deleting this entry
512
+ // from PROVISIONAL_BETA_COMMANDS would leave every existing test green
513
+ // while the docs and reality diverge silently.
514
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games ai-disclosure <appid> --json"));
515
+ });
516
+
405
517
  // --- gmc games language-topics (NAK-455) ---------------------------------
406
518
 
407
519
  test("games language-topics targets the canonical route and sends no invented parameters", () => {
@@ -480,7 +592,7 @@ test("games language-topics rejects an out-of-range min_reviews before any reque
480
592
  /**
481
593
  * `Number("1e2")` and `Number("0x64")` are both 100, so a `Number`-based bounds
482
594
  * check would forward these spellings to an endpoint that rejects them:
483
- * `parseIntParam` (src/server/gmc/api/validation.ts) tests `/^-?\d+$/` and
595
+ * `parseIntParam` (in the app's API validation module) tests `/^-?\d+$/` and
484
596
  * answers 400 `min_reviews must be an integer`. The CLI must not turn a string
485
597
  * the API refuses into a successful request.
486
598
  */
@@ -921,8 +1033,8 @@ test("(NAK-587) an omitted --catalog-as-of leaves the query untouched", () => {
921
1033
  * NAK-773. `--ai-disclosure` reaches games search/count/aggregate through the
922
1034
  * SAME shared query builder as every other filter, and is forwarded verbatim
923
1035
  * (kebab-case flag to camelCase query param) as the three literal values the
924
- * server matches case-sensitively (assertAiDisclosureFilterValue,
925
- * src/server/gmc/repositories/ai-disclosure.ts) -- the CLI does not
1036
+ * server matches case-sensitively (assertAiDisclosureFilterValue, in the
1037
+ * app's AI-disclosure repository module) -- the CLI does not
926
1038
  * lower-case or trim a typo into a value the caller did not send.
927
1039
  */
928
1040
  test("(NAK-773) games search/count/aggregate forward --ai-disclosure as the aiDisclosure query param", () => {
@@ -1022,6 +1134,89 @@ test("(NAK-773) --ai-disclosure composes with the release_month,ai_disclosure cr
1022
1134
  assert.equal(request.query.aiDisclosure, "unconfirmed");
1023
1135
  });
1024
1136
 
1137
+ /**
1138
+ * NAK-803. `--release-status` closes the CLI/REST divergence the
1139
+ * AI-disclosure chart-ready recipe documented: `releaseStatus` was reachable
1140
+ * on the REST endpoint but had no CLI mapping, so the CLI variant of a
1141
+ * "released games only" question had to rely on the release window alone.
1142
+ * The three values are the closed set the server enforces
1143
+ * (RELEASE_STATUS_FILTER_VALUES in the app's games repository module, and
1144
+ * the same literal triple the market RPCs re-check), so the CLI rejects
1145
+ * anything outside it locally rather than normalizing a typo into a value
1146
+ * the caller did not send.
1147
+ */
1148
+ test("(NAK-803) games search/count/aggregate forward --release-status as the releaseStatus query param", () => {
1149
+ const search = buildRequestFromArgv([
1150
+ "games", "search", "--source", "steam", "--release-status", "released", "--json"
1151
+ ]);
1152
+ assert.equal(search.query.releaseStatus, "released");
1153
+
1154
+ const count = buildRequestFromArgv([
1155
+ "games", "count", "--source", "steam", "--release-status", "upcoming", "--json"
1156
+ ]);
1157
+ assert.equal(count.query.releaseStatus, "upcoming");
1158
+
1159
+ const aggregate = buildRequestFromArgv([
1160
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1161
+ "--release-status", "tba", "--json"
1162
+ ]);
1163
+ assert.equal(aggregate.query.releaseStatus, "tba");
1164
+ });
1165
+
1166
+ test("(NAK-803) --release-status accepts each of the three enum values", () => {
1167
+ for (const value of ["released", "upcoming", "tba"]) {
1168
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", value, "--json"]);
1169
+ assert.equal(request.query.releaseStatus, value);
1170
+ }
1171
+ });
1172
+
1173
+ test("(NAK-803) --release-status composes with the other filters instead of replacing them", () => {
1174
+ const request = buildRequestFromArgv([
1175
+ "games", "count", "--source", "steam",
1176
+ "--release-status", "released",
1177
+ "--ai-disclosure", "disclosed",
1178
+ "--tags", "roguelite",
1179
+ "--rating-min", "80",
1180
+ "--json"
1181
+ ]);
1182
+ assert.equal(request.query.releaseStatus, "released");
1183
+ assert.equal(request.query.aiDisclosure, "disclosed");
1184
+ assert.equal(request.query.tags, "roguelite");
1185
+ assert.equal(request.query.ratingMin, "80");
1186
+ });
1187
+
1188
+ test("(NAK-803) an invalid --release-status value is a usage error, not a silently unfiltered request", () => {
1189
+ const { output, exitCode } = captureUsageFailure(() =>
1190
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", "launched", "--json"])
1191
+ );
1192
+ assert.equal(exitCode, 2);
1193
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1194
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1195
+ });
1196
+
1197
+ test("(NAK-803) --release-status is case-sensitive: a differently-cased value is rejected, not normalized", () => {
1198
+ const { output, exitCode } = captureUsageFailure(() =>
1199
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", "Released", "--json"])
1200
+ );
1201
+ assert.equal(exitCode, 2);
1202
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1203
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1204
+ });
1205
+
1206
+ test("(NAK-803) a blank --release-status is a usage error, not a silently unfiltered request", () => {
1207
+ const { output, exitCode } = captureUsageFailure(() =>
1208
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", " ", "--json"])
1209
+ );
1210
+ assert.equal(exitCode, 2);
1211
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1212
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1213
+ });
1214
+
1215
+ test("(NAK-803) an omitted --release-status leaves the query untouched", () => {
1216
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
1217
+ assert.equal("releaseStatus" in request.query, false);
1218
+ });
1219
+
1025
1220
  /**
1026
1221
  * NAK-706: `gmc steam tag-trends`.
1027
1222
  *
@@ -1102,3 +1297,174 @@ test('trend-stories preserves story, edition, locale and list pagination options
1102
1297
  assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', '--family', 'mood'])).exitCode, 2);
1103
1298
  assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', 'unexpected'])).exitCode, 2);
1104
1299
  });
1300
+
1301
+ /**
1302
+ * NAK-715: `gmc roblox ...`.
1303
+ *
1304
+ * The CLI is a pass-through here on the same rule the tag-trends block above
1305
+ * documents — the per-endpoint allowlists, enum members, limit ceilings and
1306
+ * window bounds live on the server — so these tests pin the MAPPING (flag name
1307
+ * to query parameter, omission when unset, path construction) and the one
1308
+ * thing the CLI does own: a universe id is a STRING and is not a Steam appid.
1309
+ */
1310
+ test("(NAK-715) roblox charts defaults to the bare endpoint and lets the server pick the cohort", () => {
1311
+ const request = buildRequestFromArgv(["roblox", "charts", "--json"]);
1312
+
1313
+ assert.equal(request.path, "/api/v1/roblox/charts");
1314
+ // Empty, not {country: "all"}: a default copied here could drift from the
1315
+ // server's.
1316
+ assert.deepEqual(request.query, {});
1317
+ });
1318
+
1319
+ test("(NAK-715) roblox charts maps every flag to its query parameter", () => {
1320
+ const request = buildRequestFromArgv([
1321
+ "roblox", "charts",
1322
+ "--country", "JP",
1323
+ "--device", "computer",
1324
+ "--sort", "top-revisited",
1325
+ "--as-of", "2026-09-14T00:00:00.000Z",
1326
+ "--limit", "50",
1327
+ "--offset", "10",
1328
+ "--json"
1329
+ ]);
1330
+
1331
+ assert.deepEqual(request.query, {
1332
+ country: "JP",
1333
+ device: "computer",
1334
+ // Still forwarded although the source stopped publishing this sort: a
1335
+ // historical cutoff has real data, and the server reports the gap.
1336
+ sort: "top-revisited",
1337
+ // The kebab-case flag reaches the endpoint's snake_case parameter.
1338
+ as_of: "2026-09-14T00:00:00.000Z",
1339
+ limit: "50",
1340
+ offset: "10"
1341
+ });
1342
+ });
1343
+
1344
+ test("(NAK-715) roblox experiences maps the browse filters and the cursor", () => {
1345
+ const request = buildRequestFromArgv([
1346
+ "roblox", "experiences",
1347
+ "--country", "US",
1348
+ "--eligibility-days", "30",
1349
+ "--min-player-count", "1000",
1350
+ "--max-rank", "50",
1351
+ "--genre", "Adventure",
1352
+ "--creator-type", "Group",
1353
+ "--detail", "chart_only",
1354
+ "--order-by", "player_count",
1355
+ "--order", "desc",
1356
+ "--limit", "25",
1357
+ "--cursor", "abc.def",
1358
+ "--json"
1359
+ ]);
1360
+
1361
+ assert.equal(request.path, "/api/v1/roblox/experiences");
1362
+ assert.deepEqual(request.query, {
1363
+ country: "US",
1364
+ eligibility_days: "30",
1365
+ min_player_count: "1000",
1366
+ max_rank: "50",
1367
+ genre: "Adventure",
1368
+ creator_type: "Group",
1369
+ detail: "chart_only",
1370
+ order_by: "player_count",
1371
+ order: "desc",
1372
+ limit: "25",
1373
+ cursor: "abc.def"
1374
+ });
1375
+ });
1376
+
1377
+ /**
1378
+ * The id is carried as the decimal STRING the user typed. Parsing it into a
1379
+ * number would round anything above 2^53 into a different experience's id.
1380
+ */
1381
+ test("(NAK-715) a universe id travels as a decimal string, never as a number", () => {
1382
+ const request = buildRequestFromArgv(["roblox", "experience", "9007199254740993", "--json"]);
1383
+
1384
+ assert.equal(request.path, "/api/v1/roblox/experiences/9007199254740993");
1385
+ assert.deepEqual(request.query, {});
1386
+ });
1387
+
1388
+ test("(NAK-715) roblox history builds the history path and maps the window flags", () => {
1389
+ const request = buildRequestFromArgv([
1390
+ "roblox", "history", "4924922222",
1391
+ "--granularity", "hourly",
1392
+ "--from", "2026-09-13T00:00:00.000Z",
1393
+ "--to", "2026-09-20T00:00:00.000Z",
1394
+ "--json"
1395
+ ]);
1396
+
1397
+ assert.equal(request.path, "/api/v1/roblox/experiences/4924922222/history");
1398
+ assert.deepEqual(request.query, {
1399
+ granularity: "hourly",
1400
+ from: "2026-09-13T00:00:00.000Z",
1401
+ to: "2026-09-20T00:00:00.000Z"
1402
+ });
1403
+ });
1404
+
1405
+ test("(NAK-715) roblox coverage takes only the cutoff", () => {
1406
+ assert.deepEqual(buildRequestFromArgv(["roblox", "coverage", "--json"]), {
1407
+ path: "/api/v1/roblox/coverage",
1408
+ query: {}
1409
+ });
1410
+
1411
+ const { exitCode } = captureUsageFailure(() =>
1412
+ buildRequestFromArgv(["roblox", "coverage", "--country", "JP", "--json"])
1413
+ );
1414
+ assert.equal(exitCode, 2);
1415
+ });
1416
+
1417
+ /**
1418
+ * A Steam appid spelling the app's validator rejects must not reach the wire
1419
+ * looking fine, and it must never be mistaken for a universe id.
1420
+ */
1421
+ test("(NAK-715) a malformed universe id is a usage error naming the Steam-appid confusion", () => {
1422
+ for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
1423
+ const { output, exitCode } = captureUsageFailure(() =>
1424
+ buildRequestFromArgv(["roblox", "experience", bad, "--json"])
1425
+ );
1426
+ assert.equal(exitCode, 2, `expected ${bad} to be refused`);
1427
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1428
+ assert.match(output.error.message, /not a Steam appid/);
1429
+ }
1430
+
1431
+ const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", "history", "--json"]));
1432
+ assert.equal(missing.exitCode, 2);
1433
+ });
1434
+
1435
+ test("(NAK-715) a flag the endpoint has no parameter for is a usage error, not a dropped narrowing", () => {
1436
+ const { output, exitCode } = captureUsageFailure(() =>
1437
+ buildRequestFromArgv(["roblox", "charts", "--source", "steam", "--json"])
1438
+ );
1439
+
1440
+ assert.equal(exitCode, 2);
1441
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1442
+ assert.match(output.error.message, /--source is not supported by gmc roblox charts/);
1443
+ });
1444
+
1445
+ test("(NAK-715) a stray positional is a usage error rather than a silently ignored argument", () => {
1446
+ const { exitCode } = captureUsageFailure(() =>
1447
+ buildRequestFromArgv(["roblox", "charts", "cozy", "--json"])
1448
+ );
1449
+ assert.equal(exitCode, 2);
1450
+ });
1451
+
1452
+ /**
1453
+ * Mirrors the tag-trends precedent: removing these from
1454
+ * PROVISIONAL_BETA_COMMANDS would leave every test above green while the
1455
+ * commands silently vanished from `--help`.
1456
+ */
1457
+ test("(NAK-715) the roblox commands are published as provisional in --help", () => {
1458
+ for (const command of [
1459
+ "gmc roblox charts --country all --sort top-playing-now --json",
1460
+ "gmc roblox experiences --country all --limit 50 --json",
1461
+ "gmc roblox experience <universeId> --json",
1462
+ "gmc roblox history <universeId> --granularity daily|hourly --json",
1463
+ "gmc roblox coverage --json"
1464
+ ]) {
1465
+ assert.ok(
1466
+ PROVISIONAL_BETA_COMMANDS.includes(command),
1467
+ `${command} must stay listed as provisional`
1468
+ );
1469
+ }
1470
+ });