@witchpot/gmc 0.6.0 → 0.9.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,99 @@ 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
+
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
+
277
413
  if (matches(command, ["reviews", "patterns"])) {
278
414
  assertSource(options.source);
279
415
  const isCompare = positional[0] === "compare";
@@ -451,6 +587,69 @@ export function buildRequest(command, options, positional) {
451
587
  };
452
588
  }
453
589
 
590
+ if (matches(command, ["showcases", "history"])) {
591
+ assertSource(options.source);
592
+ const externalId = positional[0];
593
+ assertExternalId(externalId, "gmc showcases history --source steam <externalId> --json");
594
+ return {
595
+ kind: "showcaseHistory",
596
+ source: "steam",
597
+ externalId,
598
+ detailQuery: {
599
+ ...pickQuery(options, {
600
+ locale: "locale"
601
+ }),
602
+ sections: "showcases"
603
+ }
604
+ };
605
+ }
606
+
607
+ if (matches(command, ["steam", "trend-stories"])) {
608
+ assertSupportedOptions(options, { command: "gmc steam trend-stories", allowed: ["story", "edition", "tag", "locale", "limit", "offset", "fields"] });
609
+ assertNoExtraPositionals(positional, { expected: 0, usage: "gmc steam trend-stories --json" });
610
+ return { path: "/api/v1/steam/trend-stories", query: pickQuery(options, { story: "story", edition: "edition", tag: "tag", locale: "locale", limit: "limit", offset: "offset", fields: "fields" }) };
611
+ }
612
+
613
+ // NAK-706. A thin pass-through to GET /api/v1/steam/tag-trends: the CLI
614
+ // forwards the flags it was given and the SERVER owns the contract — which
615
+ // view accepts which parameter, the enum members, the per-view limit
616
+ // ceilings, and the cursor binding. Re-implementing the per-view allowlist
617
+ // here would put a second copy of it in a package that ships on its own
618
+ // release cadence, and the copy that drifts is the one that rejects a
619
+ // request the service would have answered.
620
+ //
621
+ // `assertSupportedOptions` still runs, for the narrower thing it is for: a
622
+ // flag this ENDPOINT has no parameter for (`--source`, `--locale`,
623
+ // `--offset`) would otherwise be dropped silently and answer the unnarrowed
624
+ // question with exit 0.
625
+ if (matches(command, ["steam", "tag-trends"])) {
626
+ assertSupportedOptions(options, {
627
+ command: "gmc steam tag-trends",
628
+ allowed: ["view", "family", "q", "supply-direction", "sort", "order", "limit", "cursor", "tag"]
629
+ });
630
+ assertNoExtraPositionals(positional, {
631
+ expected: 0,
632
+ usage: "gmc steam tag-trends --view <view> --json"
633
+ });
634
+ return {
635
+ path: "/api/v1/steam/tag-trends",
636
+ // Every value is omitted when unset so the server owns each default
637
+ // (view=featured, sort=release_share_delta, order=desc, and the two
638
+ // per-view limits) and no default can drift into a copy kept here.
639
+ query: pickQuery(options, {
640
+ view: "view",
641
+ family: "family",
642
+ q: "q",
643
+ "supply-direction": "supply_direction",
644
+ sort: "sort",
645
+ order: "order",
646
+ limit: "limit",
647
+ cursor: "cursor",
648
+ tag: "tag"
649
+ })
650
+ };
651
+ }
652
+
454
653
  return null;
455
654
  }
456
655
 
@@ -565,6 +764,21 @@ function buildOptionalGameCountRequest(options) {
565
764
  };
566
765
  }
567
766
 
767
+ // Mirrors AI_DISCLOSURE_FILTER_VALUES in the app's ai-disclosure repository
768
+ // module (NAK-773). Kept as a local literal
769
+ // because the CLI package cannot import server code; the server remains the
770
+ // enforcing authority and re-validates case-sensitively against the same SQL
771
+ // comparands, so a value this local check lets through is never one the
772
+ // server would have rejected.
773
+ const AI_DISCLOSURE_VALUES = ["disclosed", "absent", "unconfirmed"];
774
+
775
+ // Mirrors RELEASE_STATUS_FILTER_VALUES in the app's games repository module
776
+ // (NAK-803), which the market RPCs re-check against the same literal triple.
777
+ // Kept as a local literal for the same reason as AI_DISCLOSURE_VALUES above:
778
+ // the CLI package cannot import server code, and the server remains the
779
+ // enforcing authority.
780
+ const RELEASE_STATUS_VALUES = ["released", "upcoming", "tba"];
781
+
568
782
  function buildGameCountQuery(options) {
569
783
  const query = pickQuery(options, {
570
784
  query: "q",
@@ -587,9 +801,133 @@ function buildGameCountQuery(options) {
587
801
  assertReviewBoundOptions(options);
588
802
  if (query.tags) query.tags = normalizeTagSlugCsv(query.tags);
589
803
  if (query.coverage) query.coverage = assertCoverageCsv(query.coverage);
804
+ // NAK-247: --has-demo is a genuine VALUE option (true|false is a real
805
+ // filter distinction, unlike a presence-only BOOLEAN_OPTIONS flag), so it
806
+ // is validated here rather than being folded into `pickQuery`, which would
807
+ // forward whatever string the caller typed straight to the server. Applied
808
+ // to the shared count-query builder so games search/count/aggregate all
809
+ // honor it, the same shared surface `hasDemo` reaches server-side
810
+ // (parseGameFilterQuery in the app's games repository module).
811
+ if (options["has-demo"] != null) {
812
+ query.hasDemo = assertBooleanQueryOption(options["has-demo"], "--has-demo");
813
+ }
814
+ // NAK-207: --event-name is a plain string VALUE option, forwarded verbatim.
815
+ // Validating the slug against the registry is the SERVER's job (an unknown
816
+ // event answers INVALID_EVENT with suggestions); the CLI only refuses a
817
+ // blank value, which would otherwise be dropped by the query parser and
818
+ // leave the caller reading an unfiltered catalog as an event cohort.
819
+ if (options["event-name"] != null) {
820
+ const eventName = String(options["event-name"]).trim();
821
+ if (!eventName) {
822
+ writeJsonError({
823
+ code: "CLI_USAGE_ERROR",
824
+ message: "--event-name must be a non-empty Steam event slug"
825
+ });
826
+ process.exit(EXIT_USAGE);
827
+ }
828
+ query.eventName = eventName;
829
+ }
830
+ // NAK-587: --catalog-as-of is a FILTER, so it lives in the shared count-query
831
+ // builder and reaches games search, count and aggregate alike — unlike
832
+ // --as-of/--compare-as-of below, which are aggregate-only snapshot dials.
833
+ //
834
+ // The CLI refuses only a blank value; the DATE contract belongs to the API (a
835
+ // malformed date is BAD_REQUEST param catalog_as_of), so the CLI cannot drift
836
+ // from it by re-implementing it.
837
+ if (options["catalog-as-of"] != null) {
838
+ const catalogAsOf = String(options["catalog-as-of"]).trim();
839
+ if (!catalogAsOf) {
840
+ writeJsonError({
841
+ code: "CLI_USAGE_ERROR",
842
+ message: "--catalog-as-of must be a date in YYYY-MM-DD format"
843
+ });
844
+ process.exit(EXIT_USAGE);
845
+ }
846
+ query.catalog_as_of = catalogAsOf;
847
+ }
848
+ // NAK-773: --ai-disclosure is a FILTER, so it lives in the shared
849
+ // count-query builder and reaches games search, count and aggregate alike,
850
+ // same as --has-demo and --event-name above. The three values are a closed
851
+ // set the server enforces case-sensitively against the literal comparands
852
+ // its SQL matches (assertAiDisclosureFilterValue in the app's
853
+ // ai-disclosure repository module), so the CLI rejects
854
+ // anything outside that set locally rather than lower-casing or trimming a
855
+ // typo into a value the caller did not send. `absent` means no disclosure
856
+ // was observed in collected data, not confirmed non-use, and `unconfirmed`
857
+ // means Game Market Copilot has not yet successfully recorded a disclosure
858
+ // result either way -- the CLI forwards the value verbatim and does not
859
+ // restate that distinction.
860
+ if (options["ai-disclosure"] != null) {
861
+ query.aiDisclosure = assertEnumQueryOption(
862
+ options["ai-disclosure"],
863
+ "--ai-disclosure",
864
+ AI_DISCLOSURE_VALUES
865
+ );
866
+ }
867
+ // NAK-803: --release-status is a FILTER, so it lives in the shared
868
+ // count-query builder and reaches games search, count and aggregate alike.
869
+ // `releaseStatus` was already in the REST contract but had no CLI mapping,
870
+ // which forced the CLI form of a "released games only" question to lean on
871
+ // the release window alone (see docs/cli-use-cases.md section 67). Like
872
+ // --ai-disclosure, the three values are a closed set the server enforces
873
+ // case-sensitively, so a mistyped value is a usage error here rather than a
874
+ // silently-unfiltered, still-charged request.
875
+ //
876
+ // Note this filter is an exact match layered ON TOP of the unconditional
877
+ // stale-upcoming guard (NAK-78): a title still marked `upcoming` past its
878
+ // provisional release date is dropped from every query regardless of this
879
+ // flag, so `--release-status upcoming` never resurrects one.
880
+ if (options["release-status"] != null) {
881
+ query.releaseStatus = assertEnumQueryOption(
882
+ options["release-status"],
883
+ "--release-status",
884
+ RELEASE_STATUS_VALUES
885
+ );
886
+ }
590
887
  return query;
591
888
  }
592
889
 
890
+ /**
891
+ * Validates a VALUE-taking (not presence-only) boolean CLI option and
892
+ * returns the canonical "true"/"false" string the REST query param expects.
893
+ * A caller who mistypes `--has-demo maybe` gets a usage error here rather
894
+ * than a silently-unfiltered, still-charged request — the same
895
+ * accepted-then-dropped failure mode `assertSupportedOptions` documents for
896
+ * unknown flags entirely, but for a KNOWN flag with an invalid value.
897
+ */
898
+ function assertBooleanQueryOption(value, optionName) {
899
+ const normalized = String(value).trim().toLowerCase();
900
+ if (normalized === "true") return "true";
901
+ if (normalized === "false") return "false";
902
+ writeJsonError({
903
+ code: "CLI_USAGE_ERROR",
904
+ message: `${optionName} must be true or false`
905
+ });
906
+ process.exit(EXIT_USAGE);
907
+ }
908
+
909
+ /**
910
+ * Generic enum counterpart to `assertBooleanQueryOption` (NAK-773). No
911
+ * reusable enum-assert helper existed in this file before: the existing
912
+ * value-enum validators (`assertReviewPatternSentiment`, `assertCompareFocus`,
913
+ * `assertCoverageCsv`, `assertMoreLikeThisCountry`) are each written inline
914
+ * for their own flag, and some of them normalize case or trim first. This one
915
+ * does neither on purpose — it compares the raw string exactly, for flags
916
+ * such as --ai-disclosure whose server-side contract is deliberately
917
+ * case-sensitive, so a caller who mistypes the value (including a
918
+ * differently-cased or blank one) gets a usage error here rather than a
919
+ * silently-unfiltered, still-charged request.
920
+ */
921
+ function assertEnumQueryOption(value, optionName, allowedValues) {
922
+ const stringValue = String(value);
923
+ if (allowedValues.includes(stringValue)) return stringValue;
924
+ writeJsonError({
925
+ code: "CLI_USAGE_ERROR",
926
+ message: `${optionName} must be one of: ${allowedValues.join(", ")}`
927
+ });
928
+ process.exit(EXIT_USAGE);
929
+ }
930
+
593
931
  function buildGameAggregateQuery(options) {
594
932
  return {
595
933
  ...buildGameCountQuery(options),
@@ -599,7 +937,12 @@ function buildGameAggregateQuery(options) {
599
937
  bucket: "bucket",
600
938
  sort: "sort",
601
939
  limit: "limit",
602
- offset: "offset"
940
+ offset: "offset",
941
+ // NAK-248: time-travel and the two-point comparison. `--as-of` was
942
+ // reachable on the REST endpoint but had no CLI mapping; the comparison
943
+ // needs it to pin the end of the window, so both land together.
944
+ "as-of": "as_of",
945
+ "compare-as-of": "compare_as_of"
603
946
  })
604
947
  };
605
948
  }
@@ -767,6 +1110,104 @@ function assertReviewPatternSentiment(value) {
767
1110
  return sentiment;
768
1111
  }
769
1112
 
1113
+ // Mirrors MORE_LIKE_THIS_COUNTRIES on the server (NAK-456). Kept as a local
1114
+ // literal because the CLI package cannot import server code; the server
1115
+ // remains the enforcing authority and returns 400 for anything it rejects.
1116
+ const MORE_LIKE_THIS_COUNTRIES = ["de", "fr", "gb", "jp", "kr", "us"];
1117
+
1118
+ function assertMoreLikeThisCountry(value) {
1119
+ const country = String(value).trim().toLowerCase();
1120
+ if (!MORE_LIKE_THIS_COUNTRIES.includes(country)) {
1121
+ writeJsonError({
1122
+ code: "CLI_USAGE_ERROR",
1123
+ message: `--country must be one of: ${MORE_LIKE_THIS_COUNTRIES.join(", ")}`
1124
+ });
1125
+ process.exit(EXIT_USAGE);
1126
+ }
1127
+ return country;
1128
+ }
1129
+
1130
+ /**
1131
+ * Bounds check for `gmc games language-topics --min-reviews` (NAK-455).
1132
+ * Rejects locally with the same 10-200 integer bound the REST endpoint
1133
+ * enforces, so a typo costs a usage error rather than a round trip and an API
1134
+ * credit. The server stays the enforcing authority.
1135
+ *
1136
+ * The lexical test is deliberately `/^\d+$/` rather than `Number(value)`:
1137
+ * `Number` also accepts the exponent and hex spellings (`"1e2"`, `"0x64"` both
1138
+ * become 100), which the REST endpoint rejects outright — `parseIntParam`
1139
+ * (in the app's API validation module) tests `/^-?\d+$/` before parsing. Letting
1140
+ * those through here would mean the same string the API answers 400 for
1141
+ * silently returns a 200 through the CLI.
1142
+ */
1143
+ const DECIMAL_INTEGER_RE = /^\d+$/;
1144
+
1145
+ function assertLanguageTopicMinReviews(value) {
1146
+ const raw = String(value);
1147
+ const number = DECIMAL_INTEGER_RE.test(raw) ? Number(raw) : Number.NaN;
1148
+ if (
1149
+ !Number.isInteger(number)
1150
+ || number < LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR
1151
+ || number > LANGUAGE_TOPIC_MIN_REVIEWS_CEILING
1152
+ ) {
1153
+ writeJsonError({
1154
+ code: "CLI_USAGE_ERROR",
1155
+ message: `--min-reviews must be an integer between ${LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR} and ${LANGUAGE_TOPIC_MIN_REVIEWS_CEILING}`
1156
+ });
1157
+ process.exit(EXIT_USAGE);
1158
+ }
1159
+ return number;
1160
+ }
1161
+
1162
+ /**
1163
+ * Options every request-building command tolerates: CLI-level output shaping
1164
+ * and switches that never reach the wire.
1165
+ */
1166
+ const GLOBAL_CLI_OPTIONS = new Set(["json", "help", "version", "fields", "preset"]);
1167
+
1168
+ /**
1169
+ * NAK-428. Per-command option allowlist.
1170
+ *
1171
+ * `parseArgs` collects every `--flag` into one bag and each command reads only
1172
+ * the keys it knows, so an unsupported flag used to be dropped on the floor.
1173
+ * For a command whose endpoint takes no query parameters that silence fails in
1174
+ * one specific direction: `gmc games review-history --range 1y` returned the
1175
+ * FULL series with exit 0, and the caller who asked for a window had no signal
1176
+ * that the narrowing was ignored — the unwindowed answer reads exactly like a
1177
+ * windowed one. Naming the offending flag and exiting 2 is the only reading
1178
+ * that cannot be mistaken for success.
1179
+ *
1180
+ * Deliberately applied per command rather than globally: a blanket unknown-flag
1181
+ * rejection would change the behaviour of every other command in the same
1182
+ * change, including flags callers may already be passing. Commands opt in.
1183
+ */
1184
+ function assertSupportedOptions(options, { command, allowed }) {
1185
+ for (const key of Object.keys(options)) {
1186
+ if (GLOBAL_CLI_OPTIONS.has(key) || allowed.includes(key)) continue;
1187
+ const supported = allowed.map((name) => `--${name}`).join(", ");
1188
+ writeJsonError({
1189
+ code: "CLI_USAGE_ERROR",
1190
+ message: `--${key} is not supported by ${command}. Supported options: ${supported}. The endpoint does not accept it, so it would be ignored rather than applied.`
1191
+ });
1192
+ process.exit(EXIT_USAGE);
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Counterpart to `assertSupportedOptions` for bare arguments: a second
1198
+ * positional on a single-target command is either a typo or a second appid the
1199
+ * caller expected to be fetched, and both readings are worse when the answer
1200
+ * for the FIRST one comes back as if nothing were wrong.
1201
+ */
1202
+ function assertNoExtraPositionals(positional, { expected, usage }) {
1203
+ if (positional.length <= expected) return;
1204
+ writeJsonError({
1205
+ code: "CLI_USAGE_ERROR",
1206
+ message: `Unexpected extra argument "${positional[expected]}". Usage: ${usage}`
1207
+ });
1208
+ process.exit(EXIT_USAGE);
1209
+ }
1210
+
770
1211
  function assertExternalId(value, usage) {
771
1212
  if (!value) {
772
1213
  writeJsonError({
@@ -777,6 +1218,31 @@ function assertExternalId(value, usage) {
777
1218
  }
778
1219
  }
779
1220
 
1221
+ /**
1222
+ * NAK-804. Validates the appid positional for `gmc games ai-disclosure`,
1223
+ * mirroring the strict path-parameter check the app applies to every
1224
+ * `/api/v1/.../games/{appid}` route: a plain positive decimal integer, and
1225
+ * nothing else. The lexical test is deliberately `/^\d+$/` rather than
1226
+ * `Number(value)`, the same reasoning `assertLanguageTopicMinReviews` above
1227
+ * documents for `--min-reviews` -- `Number` also accepts the exponent, hex
1228
+ * and decimal-point spellings ("7e2", "0x2da", "730.0") that the app's
1229
+ * validator rejects outright, so letting those through here would mean a
1230
+ * value the API answers 400 for silently reaches the wire as if it were
1231
+ * fine. This check is advisory; the server remains the enforcing authority.
1232
+ */
1233
+ function assertAppid(value, usage) {
1234
+ const raw = value == null ? "" : String(value);
1235
+ const appid = DECIMAL_INTEGER_RE.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
1236
+ if (!Number.isSafeInteger(appid) || appid <= 0) {
1237
+ writeJsonError({
1238
+ code: "CLI_USAGE_ERROR",
1239
+ message: `Missing or invalid appid (must be a positive integer). Usage: ${usage}`
1240
+ });
1241
+ process.exit(EXIT_USAGE);
1242
+ }
1243
+ return appid;
1244
+ }
1245
+
780
1246
  function assertResolveQuery(value) {
781
1247
  if (value == null || String(value).trim() === "") {
782
1248
  writeJsonError({
@@ -820,6 +1286,12 @@ function assertListId(value, usage) {
820
1286
  return String(value);
821
1287
  }
822
1288
 
1289
+ /** NAK-525: usage failure that is not about a missing option. Same exit code and JSON envelope as every other usage error. */
1290
+ function exitWithUsage(message) {
1291
+ writeJsonError({ code: "CLI_USAGE_ERROR", message });
1292
+ process.exit(EXIT_USAGE);
1293
+ }
1294
+
823
1295
  function parseJsonOption(value, optionName) {
824
1296
  if (value == null) {
825
1297
  writeJsonError({