@witchpot/gmc 0.6.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/CHANGELOG.md +58 -0
- package/README.md +3 -0
- package/package.json +2 -2
- package/skill/gmc-analysis/CHANGELOG.md +172 -0
- package/skill/gmc-analysis/SKILL.md +372 -7
- package/skill/gmc-analysis.manifest.json +2 -2
- package/src/args.js +42 -4
- package/src/constants.js +20 -3
- package/src/help.js +208 -1
- package/src/index.js +1 -0
- package/src/requests.js +399 -2
- package/src/requests.test.js +870 -0
- package/src/workflows.js +15 -1
- package/src/workflows.test.js +156 -0
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({
|