@witchpot/gmc 0.5.0 → 0.8.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
@@ -9,6 +9,8 @@ import {
9
9
  DEFAULT_REVIEW_PATTERNS_MIN_GAMES,
10
10
  DEFAULT_SHOWCASE_FIT_LIMIT,
11
11
  EXIT_USAGE,
12
+ LANGUAGE_TOPIC_MIN_REVIEWS_CEILING,
13
+ LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR,
12
14
  MAX_SAMPLE_PAGES,
13
15
  MAX_SAMPLE_SIZE
14
16
  } from "./constants.js";
@@ -88,7 +90,11 @@ export function buildRequest(command, options, positional) {
88
90
  path: `/api/v1/lists/${encodeURIComponent(id)}/games`,
89
91
  query: pickQuery(options, {
90
92
  limit: "limit",
91
- offset: "offset"
93
+ offset: "offset",
94
+ // NAK-525: a resolution-backed list pages by signed cursor. Before this
95
+ // the flag was accepted and silently dropped, which walks the list by
96
+ // offset while the caller believes it is following the cursor.
97
+ cursor: "cursor"
92
98
  })
93
99
  };
94
100
  }
@@ -125,6 +131,43 @@ export function buildRequest(command, options, positional) {
125
131
  };
126
132
  }
127
133
 
134
+ // NAK-525. Non-destructive: computes (or reuses) the materialized resolution a
135
+ // natural-language list definition needs before its games can be read. Costs 2
136
+ // credits, including on a reuse.
137
+ if (matches(command, ["lists", "resolve"])) {
138
+ const id = assertListId(positional[0], "gmc lists resolve <id> --json");
139
+ return {
140
+ path: `/api/v1/lists/${encodeURIComponent(id)}/resolve`,
141
+ method: "POST",
142
+ query: {}
143
+ };
144
+ }
145
+
146
+ // NAK-525. Destructive-adjacent: freezes the list's CURRENT members, so it
147
+ // stops following its definition, and is undone only by discarding the frozen
148
+ // rows. `--confirm` is required for the same reason the tool surfaces require
149
+ // `confirm: true` — this must be a decision, not a typo.
150
+ if (matches(command, ["lists", "materialize"])) {
151
+ const id = assertListId(positional[0], "gmc lists materialize <id> --confirm --json");
152
+ if (options.confirm !== true) {
153
+ exitWithUsage(
154
+ "Freezing a list stops it from updating and can only be undone by discarding the frozen rows. Re-run with --confirm once you are sure. Usage: gmc lists materialize <id> --confirm --json"
155
+ );
156
+ }
157
+ return {
158
+ path: `/api/v1/lists/${encodeURIComponent(id)}/materialize`,
159
+ method: "POST",
160
+ query: {},
161
+ // An empty body means "freeze the current revision" (the server treats a
162
+ // malformed non-empty body as a 400 rather than an omission), and
163
+ // --expected-revision is the compare-and-swap a caller uses after an
164
+ // explicit resolve.
165
+ ...(options["expected-revision"] != null
166
+ ? { body: { expectedDefinitionRevision: assertPositiveIntegerOption(options["expected-revision"], "--expected-revision") } }
167
+ : {})
168
+ };
169
+ }
170
+
128
171
  if (matches(command, ["games", "search"])) {
129
172
  assertSource(options.source);
130
173
  return {
@@ -274,6 +317,76 @@ export function buildRequest(command, options, positional) {
274
317
  };
275
318
  }
276
319
 
320
+ // NAK-455. The parameter surface is exactly the REST parameter surface —
321
+ // `min_reviews` and `include_insufficient` — and nothing more. There is no
322
+ // language filter, topic filter, or row limit here because the API has
323
+ // none; a client-side one would make the CLI, rather than the service,
324
+ // decide which evidence a caller sees.
325
+ if (matches(command, ["games", "language-topics"])) {
326
+ assertSource(options.source);
327
+ assertSupportedOptions(options, {
328
+ command: "gmc games language-topics",
329
+ allowed: ["source", "min-reviews", "include-insufficient"]
330
+ });
331
+ const externalId = positional[0];
332
+ assertExternalId(externalId, "gmc games language-topics --source steam <externalId> --json");
333
+ assertNoExtraPositionals(positional, {
334
+ expected: 1,
335
+ usage: "gmc games language-topics --source steam <externalId> --json"
336
+ });
337
+ const minReviews = options["min-reviews"] == null
338
+ ? undefined
339
+ : assertLanguageTopicMinReviews(options["min-reviews"]);
340
+ return {
341
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/review-topics/languages`,
342
+ query: {
343
+ // Omitted when unset so the server owns the default (30) and the two
344
+ // cannot drift apart.
345
+ ...(minReviews != null ? { min_reviews: String(minReviews) } : {}),
346
+ ...(options["include-insufficient"] ? { include_insufficient: "true" } : {})
347
+ }
348
+ };
349
+ }
350
+
351
+ // NAK-428. The endpoint takes NO query parameters: the full daily series is
352
+ // always returned, and range presets are a display concern the web UI
353
+ // applies client-side. A --from/--to/--range invented here would make the
354
+ // CLI, rather than the service, decide which observations a caller sees —
355
+ // and would hide exactly the collection gaps a caller has to reason about.
356
+ //
357
+ // Not supporting them is not the same as tolerating them: a request-shaping
358
+ // flag that is accepted and dropped returns the FULL series with exit 0, so
359
+ // a caller who asked for a window reads the unwindowed answer as the
360
+ // windowed one. `assertSupportedOptions` turns that into a usage error.
361
+ if (matches(command, ["games", "review-history"])) {
362
+ assertSource(options.source);
363
+ assertSupportedOptions(options, {
364
+ command: "gmc games review-history",
365
+ allowed: ["source"]
366
+ });
367
+ const externalId = positional[0];
368
+ assertExternalId(externalId, "gmc games review-history --source steam <externalId> --json");
369
+ assertNoExtraPositionals(positional, {
370
+ expected: 1,
371
+ usage: "gmc games review-history --source steam <externalId> --json"
372
+ });
373
+ return {
374
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/review-history`,
375
+ query: {}
376
+ };
377
+ }
378
+
379
+ if (matches(command, ["games", "more-like-this"])) {
380
+ assertSource(options.source);
381
+ const externalId = positional[0];
382
+ assertExternalId(externalId, "gmc games more-like-this --source steam <externalId> --json");
383
+ const country = options.country == null ? undefined : assertMoreLikeThisCountry(options.country);
384
+ return {
385
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/more-like-this`,
386
+ query: country ? { country } : {}
387
+ };
388
+ }
389
+
277
390
  if (matches(command, ["reviews", "patterns"])) {
278
391
  assertSource(options.source);
279
392
  const isCompare = positional[0] === "compare";
@@ -451,6 +564,69 @@ export function buildRequest(command, options, positional) {
451
564
  };
452
565
  }
453
566
 
567
+ if (matches(command, ["showcases", "history"])) {
568
+ assertSource(options.source);
569
+ const externalId = positional[0];
570
+ assertExternalId(externalId, "gmc showcases history --source steam <externalId> --json");
571
+ return {
572
+ kind: "showcaseHistory",
573
+ source: "steam",
574
+ externalId,
575
+ detailQuery: {
576
+ ...pickQuery(options, {
577
+ locale: "locale"
578
+ }),
579
+ sections: "showcases"
580
+ }
581
+ };
582
+ }
583
+
584
+ if (matches(command, ["steam", "trend-stories"])) {
585
+ assertSupportedOptions(options, { command: "gmc steam trend-stories", allowed: ["story", "edition", "tag", "locale", "limit", "offset", "fields"] });
586
+ assertNoExtraPositionals(positional, { expected: 0, usage: "gmc steam trend-stories --json" });
587
+ return { path: "/api/v1/steam/trend-stories", query: pickQuery(options, { story: "story", edition: "edition", tag: "tag", locale: "locale", limit: "limit", offset: "offset", fields: "fields" }) };
588
+ }
589
+
590
+ // NAK-706. A thin pass-through to GET /api/v1/steam/tag-trends: the CLI
591
+ // forwards the flags it was given and the SERVER owns the contract — which
592
+ // view accepts which parameter, the enum members, the per-view limit
593
+ // ceilings, and the cursor binding. Re-implementing the per-view allowlist
594
+ // here would put a second copy of it in a package that ships on its own
595
+ // release cadence, and the copy that drifts is the one that rejects a
596
+ // request the service would have answered.
597
+ //
598
+ // `assertSupportedOptions` still runs, for the narrower thing it is for: a
599
+ // flag this ENDPOINT has no parameter for (`--source`, `--locale`,
600
+ // `--offset`) would otherwise be dropped silently and answer the unnarrowed
601
+ // question with exit 0.
602
+ if (matches(command, ["steam", "tag-trends"])) {
603
+ assertSupportedOptions(options, {
604
+ command: "gmc steam tag-trends",
605
+ allowed: ["view", "family", "q", "supply-direction", "sort", "order", "limit", "cursor", "tag"]
606
+ });
607
+ assertNoExtraPositionals(positional, {
608
+ expected: 0,
609
+ usage: "gmc steam tag-trends --view <view> --json"
610
+ });
611
+ return {
612
+ path: "/api/v1/steam/tag-trends",
613
+ // Every value is omitted when unset so the server owns each default
614
+ // (view=featured, sort=release_share_delta, order=desc, and the two
615
+ // per-view limits) and no default can drift into a copy kept here.
616
+ query: pickQuery(options, {
617
+ view: "view",
618
+ family: "family",
619
+ q: "q",
620
+ "supply-direction": "supply_direction",
621
+ sort: "sort",
622
+ order: "order",
623
+ limit: "limit",
624
+ cursor: "cursor",
625
+ tag: "tag"
626
+ })
627
+ };
628
+ }
629
+
454
630
  return null;
455
631
  }
456
632
 
@@ -565,6 +741,14 @@ function buildOptionalGameCountRequest(options) {
565
741
  };
566
742
  }
567
743
 
744
+ // Mirrors AI_DISCLOSURE_FILTER_VALUES in the app's ai-disclosure repository
745
+ // module (NAK-773). Kept as a local literal
746
+ // because the CLI package cannot import server code; the server remains the
747
+ // enforcing authority and re-validates case-sensitively against the same SQL
748
+ // comparands, so a value this local check lets through is never one the
749
+ // server would have rejected.
750
+ const AI_DISCLOSURE_VALUES = ["disclosed", "absent", "unconfirmed"];
751
+
568
752
  function buildGameCountQuery(options) {
569
753
  const query = pickQuery(options, {
570
754
  query: "q",
@@ -587,9 +771,113 @@ function buildGameCountQuery(options) {
587
771
  assertReviewBoundOptions(options);
588
772
  if (query.tags) query.tags = normalizeTagSlugCsv(query.tags);
589
773
  if (query.coverage) query.coverage = assertCoverageCsv(query.coverage);
774
+ // NAK-247: --has-demo is a genuine VALUE option (true|false is a real
775
+ // filter distinction, unlike a presence-only BOOLEAN_OPTIONS flag), so it
776
+ // is validated here rather than being folded into `pickQuery`, which would
777
+ // forward whatever string the caller typed straight to the server. Applied
778
+ // to the shared count-query builder so games search/count/aggregate all
779
+ // honor it, the same shared surface `hasDemo` reaches server-side
780
+ // (parseGameFilterQuery in the app's games repository module).
781
+ if (options["has-demo"] != null) {
782
+ query.hasDemo = assertBooleanQueryOption(options["has-demo"], "--has-demo");
783
+ }
784
+ // NAK-207: --event-name is a plain string VALUE option, forwarded verbatim.
785
+ // Validating the slug against the registry is the SERVER's job (an unknown
786
+ // event answers INVALID_EVENT with suggestions); the CLI only refuses a
787
+ // blank value, which would otherwise be dropped by the query parser and
788
+ // leave the caller reading an unfiltered catalog as an event cohort.
789
+ if (options["event-name"] != null) {
790
+ const eventName = String(options["event-name"]).trim();
791
+ if (!eventName) {
792
+ writeJsonError({
793
+ code: "CLI_USAGE_ERROR",
794
+ message: "--event-name must be a non-empty Steam event slug"
795
+ });
796
+ process.exit(EXIT_USAGE);
797
+ }
798
+ query.eventName = eventName;
799
+ }
800
+ // NAK-587: --catalog-as-of is a FILTER, so it lives in the shared count-query
801
+ // builder and reaches games search, count and aggregate alike — unlike
802
+ // --as-of/--compare-as-of below, which are aggregate-only snapshot dials.
803
+ //
804
+ // The CLI refuses only a blank value; the DATE contract belongs to the API (a
805
+ // malformed date is BAD_REQUEST param catalog_as_of), so the CLI cannot drift
806
+ // from it by re-implementing it.
807
+ if (options["catalog-as-of"] != null) {
808
+ const catalogAsOf = String(options["catalog-as-of"]).trim();
809
+ if (!catalogAsOf) {
810
+ writeJsonError({
811
+ code: "CLI_USAGE_ERROR",
812
+ message: "--catalog-as-of must be a date in YYYY-MM-DD format"
813
+ });
814
+ process.exit(EXIT_USAGE);
815
+ }
816
+ query.catalog_as_of = catalogAsOf;
817
+ }
818
+ // NAK-773: --ai-disclosure is a FILTER, so it lives in the shared
819
+ // count-query builder and reaches games search, count and aggregate alike,
820
+ // same as --has-demo and --event-name above. The three values are a closed
821
+ // set the server enforces case-sensitively against the literal comparands
822
+ // its SQL matches (assertAiDisclosureFilterValue in the app's
823
+ // ai-disclosure repository module), so the CLI rejects
824
+ // anything outside that set locally rather than lower-casing or trimming a
825
+ // typo into a value the caller did not send. `absent` means no disclosure
826
+ // was observed in collected data, not confirmed non-use, and `unconfirmed`
827
+ // means Game Market Copilot has not yet successfully recorded a disclosure
828
+ // result either way -- the CLI forwards the value verbatim and does not
829
+ // restate that distinction.
830
+ if (options["ai-disclosure"] != null) {
831
+ query.aiDisclosure = assertEnumQueryOption(
832
+ options["ai-disclosure"],
833
+ "--ai-disclosure",
834
+ AI_DISCLOSURE_VALUES
835
+ );
836
+ }
590
837
  return query;
591
838
  }
592
839
 
840
+ /**
841
+ * Validates a VALUE-taking (not presence-only) boolean CLI option and
842
+ * returns the canonical "true"/"false" string the REST query param expects.
843
+ * A caller who mistypes `--has-demo maybe` gets a usage error here rather
844
+ * than a silently-unfiltered, still-charged request — the same
845
+ * accepted-then-dropped failure mode `assertSupportedOptions` documents for
846
+ * unknown flags entirely, but for a KNOWN flag with an invalid value.
847
+ */
848
+ function assertBooleanQueryOption(value, optionName) {
849
+ const normalized = String(value).trim().toLowerCase();
850
+ if (normalized === "true") return "true";
851
+ if (normalized === "false") return "false";
852
+ writeJsonError({
853
+ code: "CLI_USAGE_ERROR",
854
+ message: `${optionName} must be true or false`
855
+ });
856
+ process.exit(EXIT_USAGE);
857
+ }
858
+
859
+ /**
860
+ * Generic enum counterpart to `assertBooleanQueryOption` (NAK-773). No
861
+ * reusable enum-assert helper existed in this file before: the existing
862
+ * value-enum validators (`assertReviewPatternSentiment`, `assertCompareFocus`,
863
+ * `assertCoverageCsv`, `assertMoreLikeThisCountry`) are each written inline
864
+ * for their own flag, and some of them normalize case or trim first. This one
865
+ * does neither on purpose — it compares the raw string exactly, for flags
866
+ * such as --ai-disclosure whose server-side contract is deliberately
867
+ * case-sensitive, so a caller who mistypes the value (including a
868
+ * differently-cased or blank one) gets a usage error here rather than a
869
+ * silently-unfiltered, still-charged request.
870
+ */
871
+ function assertEnumQueryOption(value, optionName, allowedValues) {
872
+ const stringValue = String(value);
873
+ if (allowedValues.includes(stringValue)) return stringValue;
874
+ writeJsonError({
875
+ code: "CLI_USAGE_ERROR",
876
+ message: `${optionName} must be one of: ${allowedValues.join(", ")}`
877
+ });
878
+ process.exit(EXIT_USAGE);
879
+ }
880
+
593
881
  function buildGameAggregateQuery(options) {
594
882
  return {
595
883
  ...buildGameCountQuery(options),
@@ -599,7 +887,12 @@ function buildGameAggregateQuery(options) {
599
887
  bucket: "bucket",
600
888
  sort: "sort",
601
889
  limit: "limit",
602
- offset: "offset"
890
+ offset: "offset",
891
+ // NAK-248: time-travel and the two-point comparison. `--as-of` was
892
+ // reachable on the REST endpoint but had no CLI mapping; the comparison
893
+ // needs it to pin the end of the window, so both land together.
894
+ "as-of": "as_of",
895
+ "compare-as-of": "compare_as_of"
603
896
  })
604
897
  };
605
898
  }
@@ -767,6 +1060,104 @@ function assertReviewPatternSentiment(value) {
767
1060
  return sentiment;
768
1061
  }
769
1062
 
1063
+ // Mirrors MORE_LIKE_THIS_COUNTRIES on the server (NAK-456). Kept as a local
1064
+ // literal because the CLI package cannot import server code; the server
1065
+ // remains the enforcing authority and returns 400 for anything it rejects.
1066
+ const MORE_LIKE_THIS_COUNTRIES = ["de", "fr", "gb", "jp", "kr", "us"];
1067
+
1068
+ function assertMoreLikeThisCountry(value) {
1069
+ const country = String(value).trim().toLowerCase();
1070
+ if (!MORE_LIKE_THIS_COUNTRIES.includes(country)) {
1071
+ writeJsonError({
1072
+ code: "CLI_USAGE_ERROR",
1073
+ message: `--country must be one of: ${MORE_LIKE_THIS_COUNTRIES.join(", ")}`
1074
+ });
1075
+ process.exit(EXIT_USAGE);
1076
+ }
1077
+ return country;
1078
+ }
1079
+
1080
+ /**
1081
+ * Bounds check for `gmc games language-topics --min-reviews` (NAK-455).
1082
+ * Rejects locally with the same 10-200 integer bound the REST endpoint
1083
+ * enforces, so a typo costs a usage error rather than a round trip and an API
1084
+ * credit. The server stays the enforcing authority.
1085
+ *
1086
+ * The lexical test is deliberately `/^\d+$/` rather than `Number(value)`:
1087
+ * `Number` also accepts the exponent and hex spellings (`"1e2"`, `"0x64"` both
1088
+ * become 100), which the REST endpoint rejects outright — `parseIntParam`
1089
+ * (in the app's API validation module) tests `/^-?\d+$/` before parsing. Letting
1090
+ * those through here would mean the same string the API answers 400 for
1091
+ * silently returns a 200 through the CLI.
1092
+ */
1093
+ const DECIMAL_INTEGER_RE = /^\d+$/;
1094
+
1095
+ function assertLanguageTopicMinReviews(value) {
1096
+ const raw = String(value);
1097
+ const number = DECIMAL_INTEGER_RE.test(raw) ? Number(raw) : Number.NaN;
1098
+ if (
1099
+ !Number.isInteger(number)
1100
+ || number < LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR
1101
+ || number > LANGUAGE_TOPIC_MIN_REVIEWS_CEILING
1102
+ ) {
1103
+ writeJsonError({
1104
+ code: "CLI_USAGE_ERROR",
1105
+ message: `--min-reviews must be an integer between ${LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR} and ${LANGUAGE_TOPIC_MIN_REVIEWS_CEILING}`
1106
+ });
1107
+ process.exit(EXIT_USAGE);
1108
+ }
1109
+ return number;
1110
+ }
1111
+
1112
+ /**
1113
+ * Options every request-building command tolerates: CLI-level output shaping
1114
+ * and switches that never reach the wire.
1115
+ */
1116
+ const GLOBAL_CLI_OPTIONS = new Set(["json", "help", "version", "fields", "preset"]);
1117
+
1118
+ /**
1119
+ * NAK-428. Per-command option allowlist.
1120
+ *
1121
+ * `parseArgs` collects every `--flag` into one bag and each command reads only
1122
+ * the keys it knows, so an unsupported flag used to be dropped on the floor.
1123
+ * For a command whose endpoint takes no query parameters that silence fails in
1124
+ * one specific direction: `gmc games review-history --range 1y` returned the
1125
+ * FULL series with exit 0, and the caller who asked for a window had no signal
1126
+ * that the narrowing was ignored — the unwindowed answer reads exactly like a
1127
+ * windowed one. Naming the offending flag and exiting 2 is the only reading
1128
+ * that cannot be mistaken for success.
1129
+ *
1130
+ * Deliberately applied per command rather than globally: a blanket unknown-flag
1131
+ * rejection would change the behaviour of every other command in the same
1132
+ * change, including flags callers may already be passing. Commands opt in.
1133
+ */
1134
+ function assertSupportedOptions(options, { command, allowed }) {
1135
+ for (const key of Object.keys(options)) {
1136
+ if (GLOBAL_CLI_OPTIONS.has(key) || allowed.includes(key)) continue;
1137
+ const supported = allowed.map((name) => `--${name}`).join(", ");
1138
+ writeJsonError({
1139
+ code: "CLI_USAGE_ERROR",
1140
+ message: `--${key} is not supported by ${command}. Supported options: ${supported}. The endpoint does not accept it, so it would be ignored rather than applied.`
1141
+ });
1142
+ process.exit(EXIT_USAGE);
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Counterpart to `assertSupportedOptions` for bare arguments: a second
1148
+ * positional on a single-target command is either a typo or a second appid the
1149
+ * caller expected to be fetched, and both readings are worse when the answer
1150
+ * for the FIRST one comes back as if nothing were wrong.
1151
+ */
1152
+ function assertNoExtraPositionals(positional, { expected, usage }) {
1153
+ if (positional.length <= expected) return;
1154
+ writeJsonError({
1155
+ code: "CLI_USAGE_ERROR",
1156
+ message: `Unexpected extra argument "${positional[expected]}". Usage: ${usage}`
1157
+ });
1158
+ process.exit(EXIT_USAGE);
1159
+ }
1160
+
770
1161
  function assertExternalId(value, usage) {
771
1162
  if (!value) {
772
1163
  writeJsonError({
@@ -820,6 +1211,12 @@ function assertListId(value, usage) {
820
1211
  return String(value);
821
1212
  }
822
1213
 
1214
+ /** NAK-525: usage failure that is not about a missing option. Same exit code and JSON envelope as every other usage error. */
1215
+ function exitWithUsage(message) {
1216
+ writeJsonError({ code: "CLI_USAGE_ERROR", message });
1217
+ process.exit(EXIT_USAGE);
1218
+ }
1219
+
823
1220
  function parseJsonOption(value, optionName) {
824
1221
  if (value == null) {
825
1222
  writeJsonError({