@witchpot/gmc 0.10.0 → 0.11.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 +60 -0
- package/package.json +1 -1
- package/skill/gmc-analysis/CHANGELOG.md +22 -0
- package/skill/gmc-analysis/SKILL.md +69 -2
- package/skill/gmc-analysis.manifest.json +2 -2
- package/src/args.js +23 -2
- package/src/constants.js +15 -2
- package/src/help.js +113 -2
- package/src/requests.js +240 -3
- package/src/requests.test.js +374 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Game Market Copilot CLI will be recorded here.
|
|
4
4
|
|
|
5
|
+
## 0.11.0 - 2026-10-27
|
|
6
|
+
|
|
7
|
+
- `gmc games follower-history <appid>`: observed Steam follower series (same window flags as `price-history`). MCP: `game_follower_history`.
|
|
8
|
+
- `gmc games events <appid> [--include rank_series]`: Steam event participations with observed ranks and shelves. MCP: `game_steam_events`.
|
|
9
|
+
- The bundled `gmc-analysis` skill 0.17.0 covers both commands, and the
|
|
10
|
+
optional `positiveReviews` field on `gmc games review-history` points (state
|
|
11
|
+
a rate as `positiveReviews / totalReviews` when it is present).
|
|
12
|
+
|
|
13
|
+
- New `gmc games price-history <appid>` and `gmc games screenshot-labels
|
|
14
|
+
<appid>` commands (NAK-934, Provisional), the same plain-appid shape as
|
|
15
|
+
`gmc games ai-disclosure`. `price-history` returns the observed Steam
|
|
16
|
+
storefront price on every observed day (major currency units, with
|
|
17
|
+
discount, currency and store country) and takes `--range 7d|1m|1y|all`
|
|
18
|
+
(default `all`) or `--from`/`--to` (`YYYY-MM-DD`, real calendar dates,
|
|
19
|
+
`--from` on or before `--to`), never both; a bad window is a usage error
|
|
20
|
+
before any request. Days without an observation
|
|
21
|
+
are absent, never filled, and a price never implies sales.
|
|
22
|
+
`screenshot-labels` returns one set of visual labels per Steam store
|
|
23
|
+
screenshot and takes no flags. The labels are model output, not Steam
|
|
24
|
+
data. `docs/cli-use-cases.md` and `docs/agent-usage.md` cover both.
|
|
25
|
+
|
|
26
|
+
- Not shipped here: the bundled `gmc-analysis` skill does not mention the
|
|
27
|
+
two new commands yet. It is a separately versioned artifact with its own
|
|
28
|
+
manifest gate, so its coverage follows in its own change.
|
|
29
|
+
|
|
30
|
+
- New `gmc roblox saved | save | remove` commands (NAK-723, Provisional),
|
|
31
|
+
mapping the saved-experiences surface 0.10.0 recorded as "not yet
|
|
32
|
+
mapped". `gmc roblox saved --json` lists the workspace's saved
|
|
33
|
+
experiences resolved for one chart, country and cutoff (`--country`,
|
|
34
|
+
`--device`, `--sort`, `--as-of`, `--eligibility-days`); `gmc roblox save
|
|
35
|
+
<universeId>` and `gmc roblox remove <universeId>` change the set and
|
|
36
|
+
take no flags at all, refusing a scope flag rather than ignoring it.
|
|
37
|
+
|
|
38
|
+
Unlike the five read commands these are workspace-scoped: they need an
|
|
39
|
+
API key or CLI credential and answer 401 without one, which is not an
|
|
40
|
+
empty saved list. They charge no credits — saving is workspace metadata,
|
|
41
|
+
not analytics — and the paid-plan gate still applies.
|
|
42
|
+
|
|
43
|
+
Saved membership is scope-independent: the flags on `saved` decide which
|
|
44
|
+
of `in_scope` / `out_of_scope` / `not_tracked` each row is in, never
|
|
45
|
+
which rows are listed. Both writes are idempotent and report the state
|
|
46
|
+
that now holds, so `changed: false` is success rather than a write to
|
|
47
|
+
retry. Universe ids travel as decimal strings into the request body as
|
|
48
|
+
well as the path, and the CLI rejects a spelling the server would reject
|
|
49
|
+
before any request is sent.
|
|
50
|
+
|
|
51
|
+
`docs/cli-use-cases.md` (new case 70) and `docs/agent-usage.md` now cover
|
|
52
|
+
the saved commands alongside the read family.
|
|
53
|
+
|
|
54
|
+
- The 0.10.0 entry below is left exactly as published. Its "not yet
|
|
55
|
+
mapped" line was true of 0.10.0 and is corrected by this entry rather
|
|
56
|
+
than edited in place, matching how 0.8.0 recorded the 0.7.0 registry gap:
|
|
57
|
+
a published entry describes the release users installed, so amending one
|
|
58
|
+
would make the repo disagree with the tarball already on npm.
|
|
59
|
+
|
|
60
|
+
- Not shipped here: the bundled `gmc-analysis` skill still documents the
|
|
61
|
+
Roblox read family only. The skill is a separately versioned artifact
|
|
62
|
+
with its own manifest gate, so its Roblox-saved coverage and version bump
|
|
63
|
+
follow in their own change.
|
|
64
|
+
|
|
5
65
|
## 0.10.0 - 2026-09-22
|
|
6
66
|
|
|
7
67
|
- New `gmc roblox` command family (NAK-715, Provisional): `charts` (one
|
package/package.json
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# gmc-analysis skill changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 - 2026-10-08
|
|
4
|
+
|
|
5
|
+
- Review-count history (NAK-934): each `series[]` point can carry the
|
|
6
|
+
optional `positiveReviews`, the positive-review count observed on the same
|
|
7
|
+
row, beside the optional `ratingPercent`. Both are absent, never zero, when
|
|
8
|
+
the row carried no usable value. New guardrail: state a rate as
|
|
9
|
+
`positiveReviews / totalReviews` when the count is present, because
|
|
10
|
+
`ratingPercent` is rounded to one decimal (1,999 of 2,000 reads 100.0);
|
|
11
|
+
without the count, a `100.0` on 2,000 or more reviews is possibly rounded
|
|
12
|
+
and is never reported as "no negative reviews".
|
|
13
|
+
- New "Steam follower history" section: `gmc games follower-history <appid>`
|
|
14
|
+
(MCP: `game_follower_history`), its `--range` or `--from`/`--to` window
|
|
15
|
+
(never both), and the guardrails: observed days only, followers are not
|
|
16
|
+
wishlists or sales, `not_collected` is a collection state, truncation
|
|
17
|
+
drops the oldest points.
|
|
18
|
+
- New "Steam event participation" section: `gmc games events <appid>
|
|
19
|
+
[--include rank_series]` (MCP: `game_steam_events`), with the guardrails:
|
|
20
|
+
ranks and shelves are observed event-page state, not an official ranking;
|
|
21
|
+
an absent block or an empty list is a collection state; the rank series is
|
|
22
|
+
raw and never resampled.
|
|
23
|
+
- MCP-only mapping updated with both tools.
|
|
24
|
+
|
|
3
25
|
## 0.16.0 - 2026-09-16
|
|
4
26
|
|
|
5
27
|
- New "Disclosure text and change history" subsection under "AI disclosure
|
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
or campaigns that GMC can answer — including turning results into
|
|
9
9
|
publish-ready branded chart cards.
|
|
10
10
|
metadata:
|
|
11
|
-
version: 0.
|
|
11
|
+
version: 0.17.0
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# GMC Analysis
|
|
@@ -59,7 +59,9 @@ applies identically. Mechanics map as follows:
|
|
|
59
59
|
its lifecycle/announcement/discount context (`games review-history`) ->
|
|
60
60
|
`game_review_history`; per-title AI-disclosure text and change history
|
|
61
61
|
(`games ai-disclosure`) -> `game_profile` with `ai_disclosure` in
|
|
62
|
-
`sections
|
|
62
|
+
`sections`; Steam follower history (`games follower-history`) ->
|
|
63
|
+
`game_follower_history`; Steam event participation (`games events`) ->
|
|
64
|
+
`game_steam_events` (`include: ["rank_series"]` for the rank series).
|
|
63
65
|
- Per-family collection status ("has GMC actually collected X for this
|
|
64
66
|
game?") has no CLI command yet and is MCP-only: `game_collection_status`
|
|
65
67
|
(see the dedicated section below).
|
|
@@ -413,6 +415,13 @@ Field paths differ by surface: the CLI and REST wrap the payload, so read
|
|
|
413
415
|
the same fields at the top level. The guardrails below name the CLI path;
|
|
414
416
|
drop the `data.` prefix over MCP.
|
|
415
417
|
|
|
418
|
+
Each `data.series[]` point carries `date` and `totalReviews`, and two
|
|
419
|
+
OPTIONAL fields: `ratingPercent` (the positive-review percentage observed on
|
|
420
|
+
that row, 0-100, rounded to one decimal) and `positiveReviews` (the
|
|
421
|
+
positive-review count observed on that row, 0 to `totalReviews`). Absent
|
|
422
|
+
means the row carried no usable value; neither is ever zero-filled, and a
|
|
423
|
+
returned `0` is a real observation.
|
|
424
|
+
|
|
416
425
|
Interpretation guardrails (honesty rules, not suggestions):
|
|
417
426
|
|
|
418
427
|
- **Scale by date, never by array index.** Collection has real gaps, so two
|
|
@@ -439,9 +448,67 @@ Interpretation guardrails (honesty rules, not suggestions):
|
|
|
439
448
|
day from it. `endDate: null` means no end has been observed yet;
|
|
440
449
|
`discountPercent` is the opening observation, not the deepest or final
|
|
441
450
|
depth.
|
|
451
|
+
- **State a rate from the count when it is there.** `ratingPercent` is
|
|
452
|
+
rounded to one decimal, so 1,999 positive of 2,000 reads `100.0`. When
|
|
453
|
+
`positiveReviews` is present, compute the rate as
|
|
454
|
+
`positiveReviews / totalReviews`. When it is absent and `ratingPercent` is
|
|
455
|
+
`100.0` on `totalReviews` of 2,000 or more, treat the 100 as possibly
|
|
456
|
+
rounded (anything from 99.95% up): say "about 100% (rounded)", never "no
|
|
457
|
+
negative reviews". Below 2,000 reviews a `100.0` is exact, because one
|
|
458
|
+
negative review already reads 99.9 or less.
|
|
442
459
|
- **Temporal comparison only.** "Reviews rose in the week after the update"
|
|
443
460
|
is a claim you can make; "the update caused the rise" is not.
|
|
444
461
|
|
|
462
|
+
## Steam follower history
|
|
463
|
+
|
|
464
|
+
`gmc games follower-history <appid> [--range 7d|1m|1y|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] --json`
|
|
465
|
+
(MCP: `game_follower_history` with `appid` and either `range` or
|
|
466
|
+
`from`/`to`) returns one game's Steam Community group member count on every
|
|
467
|
+
OBSERVED day: `data.series[]` of `date` and `followerCount`, plus
|
|
468
|
+
`data.availability` and `data.seriesMeta`. It takes the appid alone (no
|
|
469
|
+
`--source`). `--range` defaults to `all`, ending today (UTC); `--from` and
|
|
470
|
+
`--to` are inclusive. `--range` cannot be combined with `--from`/`--to`:
|
|
471
|
+
the CLI rejects that as a usage error before any request, so the failure is
|
|
472
|
+
not transient.
|
|
473
|
+
|
|
474
|
+
Interpretation guardrails:
|
|
475
|
+
|
|
476
|
+
- **Observed days only.** Days without an observation are absent and never
|
|
477
|
+
filled or interpolated. Scale by date, never by array index.
|
|
478
|
+
- **Followers are not wishlists.** They are Steam Community game-group
|
|
479
|
+
members, a public proxy for audience interest. Never report them as a
|
|
480
|
+
wishlist count and never as sales.
|
|
481
|
+
- **`not_collected` is a collection state.** `data.availability:
|
|
482
|
+
not_collected` means followers were never collected, never zero followers.
|
|
483
|
+
- **Truncation drops the oldest points.** When `data.seriesMeta.truncated`
|
|
484
|
+
is true the series still ends at the newest observation; say the early
|
|
485
|
+
history is cut.
|
|
486
|
+
|
|
487
|
+
## Steam event participation
|
|
488
|
+
|
|
489
|
+
`gmc games events <appid> [--include rank_series] --json` (MCP:
|
|
490
|
+
`game_steam_events`, with `include: ["rank_series"]`) returns the
|
|
491
|
+
Steam-run events the game took part in (Next Fest and others), newest
|
|
492
|
+
first: each with its observed best rank, shelf exposure, demo review
|
|
493
|
+
series, store follower series and social account deltas. `--include
|
|
494
|
+
rank_series` adds `rankSeries[]` of `{ snapshotUtc, rank, sourceKey }` per
|
|
495
|
+
event, bounded per event and per response (`rankSeriesTruncated` marks an
|
|
496
|
+
event a cap cut).
|
|
497
|
+
|
|
498
|
+
Interpretation guardrails:
|
|
499
|
+
|
|
500
|
+
- **Observed state, not an official ranking.** Ranks and shelf values are
|
|
501
|
+
event-page state captured by the collector. Report them as observed
|
|
502
|
+
positions, never as Steam's own ranking.
|
|
503
|
+
- **Absence is a collection state.** An absent block means that signal was
|
|
504
|
+
not collected for the event. An empty `data.events` means no observed
|
|
505
|
+
participation, not proof the game never took part.
|
|
506
|
+
- **The rank series is raw.** `rankSeries` holds the observations themselves
|
|
507
|
+
(about every 30 minutes during a festival), never resampled; a gap is a
|
|
508
|
+
real collection gap.
|
|
509
|
+
- **Temporal comparison only.** A follower or review change during an event
|
|
510
|
+
is something you observed beside it, never something the event caused.
|
|
511
|
+
|
|
445
512
|
## Per-family collection status
|
|
446
513
|
|
|
447
514
|
`game_collection_status` (MCP-only, no CLI command) returns per-signal-family
|
package/src/args.js
CHANGED
|
@@ -21,6 +21,12 @@ export function parseArgs(args) {
|
|
|
21
21
|
const command = [];
|
|
22
22
|
const positional = [];
|
|
23
23
|
const options = {};
|
|
24
|
+
// Option names given more than once. Later values still win in `options`
|
|
25
|
+
// (every command's parsing is unchanged); a command that cannot take a list
|
|
26
|
+
// reads this to reject the repetition. Non-enumerable so it never shows up
|
|
27
|
+
// as an option.
|
|
28
|
+
const repeated = new Set();
|
|
29
|
+
Object.defineProperty(options, "repeatedOptions", { value: repeated, enumerable: false });
|
|
24
30
|
|
|
25
31
|
for (let index = 0; index < args.length; index += 1) {
|
|
26
32
|
const arg = args[index];
|
|
@@ -57,6 +63,7 @@ export function parseArgs(args) {
|
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
if (inlineValue == null) index += 1;
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) repeated.add(key);
|
|
60
67
|
options[key] = value;
|
|
61
68
|
continue;
|
|
62
69
|
}
|
|
@@ -114,7 +121,7 @@ function isCommandToken(command, token) {
|
|
|
114
121
|
return ["install", "print", "status"].includes(token);
|
|
115
122
|
}
|
|
116
123
|
if (command[0] === "games") {
|
|
117
|
-
return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this", "ai-disclosure"].includes(token);
|
|
124
|
+
return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this", "ai-disclosure", "price-history", "follower-history", "events", "screenshot-labels"].includes(token);
|
|
118
125
|
}
|
|
119
126
|
if (command[0] === "reviews") {
|
|
120
127
|
return token === "patterns";
|
|
@@ -144,7 +151,21 @@ function isCommandToken(command, token) {
|
|
|
144
151
|
return ["tag-trends", "trend-stories"].includes(token);
|
|
145
152
|
}
|
|
146
153
|
if (command[0] === "roblox") {
|
|
147
|
-
|
|
154
|
+
// NAK-723 adds saved/save/remove. Same trap the read family documents, and
|
|
155
|
+
// worse for the two writes: a token missing from this list falls through to
|
|
156
|
+
// positional rather than erroring, so `gmc roblox save <universeId>` would
|
|
157
|
+
// parse as the unknown command `roblox` and exit 2 with a message naming
|
|
158
|
+
// the id rather than the subcommand.
|
|
159
|
+
return [
|
|
160
|
+
"charts",
|
|
161
|
+
"experiences",
|
|
162
|
+
"experience",
|
|
163
|
+
"history",
|
|
164
|
+
"coverage",
|
|
165
|
+
"saved",
|
|
166
|
+
"save",
|
|
167
|
+
"remove"
|
|
168
|
+
].includes(token);
|
|
148
169
|
}
|
|
149
170
|
return false;
|
|
150
171
|
}
|
package/src/constants.js
CHANGED
|
@@ -7,7 +7,7 @@ export const EXIT_CONFIG = 3;
|
|
|
7
7
|
export const EXIT_API = 4;
|
|
8
8
|
|
|
9
9
|
export const CLI_PACKAGE_NAME = "@witchpot/gmc";
|
|
10
|
-
export const CLI_VERSION = "0.
|
|
10
|
+
export const CLI_VERSION = "0.11.0";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
|
|
13
13
|
export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
|
|
@@ -64,6 +64,11 @@ export const PROVISIONAL_BETA_COMMANDS = [
|
|
|
64
64
|
// NAK-804. New command; starts Provisional per the closed-beta roadmap's
|
|
65
65
|
// default for anything not yet through broader real-data smoke testing.
|
|
66
66
|
"gmc games ai-disclosure <appid> --json",
|
|
67
|
+
// NAK-934. New commands; Provisional per the same closed-beta default.
|
|
68
|
+
"gmc games price-history <appid> --range 7d|1m|1y|all --json",
|
|
69
|
+
"gmc games follower-history <appid> --range 7d|1m|1y|all --json",
|
|
70
|
+
"gmc games events <appid> --include rank_series --json",
|
|
71
|
+
"gmc games screenshot-labels <appid> --json",
|
|
67
72
|
// NAK-715/NAK-725. Enabled in production on 2026-09-22; stays Provisional
|
|
68
73
|
// per the closed-beta roadmap's default until the surface has been through
|
|
69
74
|
// a broader real-data smoke period (the NAK-804 rule), then graduates.
|
|
@@ -71,7 +76,15 @@ export const PROVISIONAL_BETA_COMMANDS = [
|
|
|
71
76
|
"gmc roblox experiences --country all --limit 50 --json",
|
|
72
77
|
"gmc roblox experience <universeId> --json",
|
|
73
78
|
"gmc roblox history <universeId> --granularity daily|hourly --json",
|
|
74
|
-
"gmc roblox coverage --json"
|
|
79
|
+
"gmc roblox coverage --json",
|
|
80
|
+
// NAK-723. The workspace's saved Roblox experiences, mapping the REST
|
|
81
|
+
// surface that shipped with the pages. Provisional for the SAME reason the
|
|
82
|
+
// five read commands above are — they are one surface, gated by one flag,
|
|
83
|
+
// and graduating the saved commands ahead of the reads they resolve against
|
|
84
|
+
// would promise a stability the reads have not earned yet.
|
|
85
|
+
"gmc roblox saved --country all --json",
|
|
86
|
+
"gmc roblox save <universeId> --json",
|
|
87
|
+
"gmc roblox remove <universeId> --json"
|
|
75
88
|
];
|
|
76
89
|
|
|
77
90
|
export const COMPARE_FOCUSES = ["overview", "market", "reviews", "marketing", "audience", "showcases"];
|
package/src/help.js
CHANGED
|
@@ -52,6 +52,10 @@ Usage:
|
|
|
52
52
|
gmc games review-history --source steam <externalId> --json
|
|
53
53
|
gmc games more-like-this --source steam <externalId> --country <country> --json
|
|
54
54
|
gmc games ai-disclosure <appid> --json
|
|
55
|
+
gmc games price-history <appid> --range 1y --json
|
|
56
|
+
gmc games follower-history <appid> --range 1y --json
|
|
57
|
+
gmc games events <appid> --json
|
|
58
|
+
gmc games screenshot-labels <appid> --json
|
|
55
59
|
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
56
60
|
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --preset summary --json
|
|
57
61
|
gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
@@ -75,6 +79,9 @@ Usage:
|
|
|
75
79
|
gmc roblox experience <universeId> --json
|
|
76
80
|
gmc roblox history <universeId> --granularity daily --json
|
|
77
81
|
gmc roblox coverage --json
|
|
82
|
+
gmc roblox saved --country all --json
|
|
83
|
+
gmc roblox save <universeId> --json
|
|
84
|
+
gmc roblox remove <universeId> --json
|
|
78
85
|
|
|
79
86
|
Tag taxonomy:
|
|
80
87
|
gmc tags --json List the full tag dictionary (slug, name).
|
|
@@ -182,6 +189,77 @@ AI-disclosure text and history:
|
|
|
182
189
|
Example:
|
|
183
190
|
gmc games ai-disclosure 730 --json
|
|
184
191
|
|
|
192
|
+
Observed price history:
|
|
193
|
+
gmc games price-history <appid> [--range <preset>] [--from <date>] [--to <date>] --json
|
|
194
|
+
One game's Steam storefront price on
|
|
195
|
+
every OBSERVED day: priceInitial,
|
|
196
|
+
priceFinal (major currency units),
|
|
197
|
+
discountPercent, currency, storeCountry.
|
|
198
|
+
--range <preset> 7d | 1m | 1y | all, ending today (UTC).
|
|
199
|
+
Defaults to all.
|
|
200
|
+
--from <YYYY-MM-DD> Inclusive window start.
|
|
201
|
+
--to <YYYY-MM-DD> Inclusive window end; defaults to today.
|
|
202
|
+
--range cannot be combined with --from
|
|
203
|
+
or --to.
|
|
204
|
+
|
|
205
|
+
Collection is not daily for every game, so days without an observation are
|
|
206
|
+
absent and never filled; scale by date, never by array index. priceFinal 0
|
|
207
|
+
with a null priceInitial is a free game; a null priceFinal means no price
|
|
208
|
+
was shown that day. data.availability not_collected means no snapshot was
|
|
209
|
+
ever collected, never a free game. A price says nothing about units sold or
|
|
210
|
+
revenue.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
gmc games price-history 730 --range 1y --json
|
|
214
|
+
|
|
215
|
+
Steam follower history:
|
|
216
|
+
gmc games follower-history <appid> [--range <preset>] [--from <date>] [--to <date>] --json
|
|
217
|
+
Steam Community game-group members on
|
|
218
|
+
every OBSERVED day: date, followerCount.
|
|
219
|
+
--range <preset> 7d | 1m | 1y | all, ending today (UTC).
|
|
220
|
+
Defaults to all. Cannot be combined with
|
|
221
|
+
--from or --to.
|
|
222
|
+
|
|
223
|
+
Followers are a public proxy for audience interest, never a wishlist count
|
|
224
|
+
and never sales. Days without an observation are absent and never filled.
|
|
225
|
+
data.availability not_collected means followers were never collected.
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
gmc games follower-history 730 --range 1y --json
|
|
229
|
+
|
|
230
|
+
Steam event participation:
|
|
231
|
+
gmc games events <appid> [--include rank_series] --json
|
|
232
|
+
Steam-run events the game took part in
|
|
233
|
+
(Next Fest and others), newest first, with
|
|
234
|
+
observed best rank, shelf exposure, demo
|
|
235
|
+
reviews, store followers and social
|
|
236
|
+
deltas.
|
|
237
|
+
--include rank_series Attach the observed ranking series per
|
|
238
|
+
event.
|
|
239
|
+
|
|
240
|
+
Ranks and shelves are observed event-page state, never an official Steam
|
|
241
|
+
ranking. An absent block means the signal was not collected.
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
gmc games events 730 --include rank_series --json
|
|
245
|
+
|
|
246
|
+
Screenshot labels:
|
|
247
|
+
gmc games screenshot-labels <appid> --json
|
|
248
|
+
Visual labels for each Steam store
|
|
249
|
+
screenshot of one game (screen type,
|
|
250
|
+
camera, art style, lighting, colour, a
|
|
251
|
+
one-line observation), in store order.
|
|
252
|
+
There are no parameters beyond the appid.
|
|
253
|
+
|
|
254
|
+
The labels are MODEL OUTPUT (data.labels[].labeler), not Steam data and not
|
|
255
|
+
a human review, and they can be wrong. One label set is returned per
|
|
256
|
+
screenshot: the most recently labelled taxonomy version. data.availability
|
|
257
|
+
not_labelled means nothing has been labelled yet, never that the game has
|
|
258
|
+
no screenshots.
|
|
259
|
+
|
|
260
|
+
Example:
|
|
261
|
+
gmc games screenshot-labels 730 --json
|
|
262
|
+
|
|
185
263
|
Steam tag trends:
|
|
186
264
|
gmc steam tag-trends --view <view> --json
|
|
187
265
|
Tag supply trends over one frozen monthly
|
|
@@ -248,6 +326,13 @@ Roblox:
|
|
|
248
326
|
A bounded window of stored metric
|
|
249
327
|
observations.
|
|
250
328
|
gmc roblox coverage --json What the surface has, per dimension.
|
|
329
|
+
gmc roblox saved --json This WORKSPACE's saved experiences,
|
|
330
|
+
resolved for one cohort and cutoff.
|
|
331
|
+
gmc roblox save <universeId> --json
|
|
332
|
+
Save one experience to the workspace.
|
|
333
|
+
gmc roblox remove <universeId> --json
|
|
334
|
+
Remove one from the workspace's saved
|
|
335
|
+
set.
|
|
251
336
|
--country <country> all | US | JP. Selects which CHART was
|
|
252
337
|
read, never a regional metric scope.
|
|
253
338
|
--device <device> computer.
|
|
@@ -255,8 +340,8 @@ Roblox:
|
|
|
255
340
|
top-revisited | fun-with-friends.
|
|
256
341
|
--as-of <instant> ISO 8601 cutoff; defaults to now. No
|
|
257
342
|
observation after it is considered.
|
|
258
|
-
--eligibility-days <number> experiences
|
|
259
|
-
far back a chart appearance
|
|
343
|
+
--eligibility-days <number> experiences and saved, 1-30 (default 7).
|
|
344
|
+
How far back a chart appearance counts.
|
|
260
345
|
--min-player-count <number> experiences only, on the resolved row.
|
|
261
346
|
--max-rank <number> experiences only, on the resolved row.
|
|
262
347
|
--genre <genre> experiences only, exact match.
|
|
@@ -295,11 +380,37 @@ Roblox:
|
|
|
295
380
|
ROBLOX_UNAVAILABLE. That is the documented not-available state, not an
|
|
296
381
|
outage and not worth retrying on a schedule.
|
|
297
382
|
|
|
383
|
+
Saved experiences (workspace-scoped, unlike the five reads above):
|
|
384
|
+
saved, save and remove carry one workspace's own data and need a
|
|
385
|
+
credential — a Game Market Copilot API key or a CLI credential. Without
|
|
386
|
+
one they answer 401, which is not an empty saved list.
|
|
387
|
+
|
|
388
|
+
Saved membership is scope-independent. --country, --device, --sort,
|
|
389
|
+
--as-of and --eligibility-days apply to "saved" alone and decide which of
|
|
390
|
+
three states each row is in, never which rows are listed: in_scope
|
|
391
|
+
carries the observation that scope recorded, out_of_scope is saved and
|
|
392
|
+
tracked with NO reading there (never back-filled from another cohort or
|
|
393
|
+
from after the cutoff, and never reportable as zero or as removed), and
|
|
394
|
+
not_tracked has nothing recorded at all. The two writes take no flags.
|
|
395
|
+
|
|
396
|
+
Both writes are idempotent and report the state that NOW holds, so
|
|
397
|
+
changed: false is success and must not be retried as a failure. Saving
|
|
398
|
+
records interest in a universe id — it never triggers collection, however
|
|
399
|
+
often it is called, and it refuses an id the tracked cohort has never
|
|
400
|
+
recorded (404 NOT_FOUND, a different fact from ROBLOX_UNAVAILABLE).
|
|
401
|
+
"remove" removes the id from this workspace's saved set only; it deletes
|
|
402
|
+
no data and removing something not saved is changed: false, not a 404.
|
|
403
|
+
The set is capped per workspace (savedLimit in every response); past it,
|
|
404
|
+
saving answers 409 CONFLICT.
|
|
405
|
+
|
|
298
406
|
Examples:
|
|
299
407
|
gmc roblox coverage --json
|
|
300
408
|
gmc roblox charts --country JP --sort top-trending --json
|
|
301
409
|
gmc roblox experiences --min-player-count 1000 --order-by player_count --json
|
|
302
410
|
gmc roblox history <universeId> --granularity hourly --json
|
|
411
|
+
gmc roblox saved --country JP --sort top-trending --json
|
|
412
|
+
gmc roblox save <universeId> --json
|
|
413
|
+
gmc roblox remove <universeId> --json
|
|
303
414
|
|
|
304
415
|
Binary aliases:
|
|
305
416
|
gmcp and game-market-copilot run the same CLI. Use them when a shell
|
package/src/requests.js
CHANGED
|
@@ -410,6 +410,117 @@ export function buildRequest(command, options, positional) {
|
|
|
410
410
|
};
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
// NAK-934. Same route shape as `gmc games ai-disclosure`: a plain numeric
|
|
414
|
+
// appid, no --source. The window flags mirror the server grammar exactly
|
|
415
|
+
// (follower-history's `range` XOR `from`/`to`), and are validated here so a
|
|
416
|
+
// malformed window is a usage error rather than a charged 400. Omitted flags
|
|
417
|
+
// are omitted from the query so the server's `all` default stays its own.
|
|
418
|
+
if (matches(command, ["games", "price-history"])) {
|
|
419
|
+
const usage = "gmc games price-history <appid> [--range 7d|1m|1y|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] --json";
|
|
420
|
+
assertSupportedOptions(options, {
|
|
421
|
+
command: "gmc games price-history",
|
|
422
|
+
allowed: ["range", "from", "to"]
|
|
423
|
+
});
|
|
424
|
+
const appid = assertAppid(positional[0], usage);
|
|
425
|
+
assertNoExtraPositionals(positional, { expected: 1, usage });
|
|
426
|
+
if (options.range != null && (options.from != null || options.to != null)) {
|
|
427
|
+
writeJsonError({
|
|
428
|
+
code: "CLI_USAGE_ERROR",
|
|
429
|
+
message: "--range cannot be combined with --from or --to."
|
|
430
|
+
});
|
|
431
|
+
process.exit(EXIT_USAGE);
|
|
432
|
+
}
|
|
433
|
+
const range = options.range == null
|
|
434
|
+
? undefined
|
|
435
|
+
: assertEnumQueryOption(options.range, "--range", HISTORY_RANGES);
|
|
436
|
+
if (options.from != null) assertCalendarDate(options.from, "--from");
|
|
437
|
+
if (options.to != null) assertCalendarDate(options.to, "--to");
|
|
438
|
+
// Both are zero-padded YYYY-MM-DD here, so string order is date order.
|
|
439
|
+
// Only an explicit pair is checked: an omitted --to is the SERVER's today,
|
|
440
|
+
// which the local clock must not second-guess.
|
|
441
|
+
if (options.from != null && options.to != null && String(options.from) > String(options.to)) {
|
|
442
|
+
writeJsonError({
|
|
443
|
+
code: "CLI_USAGE_ERROR",
|
|
444
|
+
message: "--from must be on or before --to."
|
|
445
|
+
});
|
|
446
|
+
process.exit(EXIT_USAGE);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/price-history`,
|
|
450
|
+
query: {
|
|
451
|
+
...(range ? { range } : {}),
|
|
452
|
+
...(options.from != null ? { from: String(options.from) } : {}),
|
|
453
|
+
...(options.to != null ? { to: String(options.to) } : {})
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// NAK-934 specimen sheet. The follower-history route owns the window grammar
|
|
459
|
+
// price-history borrowed, so the flags and their local checks are identical.
|
|
460
|
+
if (matches(command, ["games", "follower-history"])) {
|
|
461
|
+
const usage = "gmc games follower-history <appid> [--range 7d|1m|1y|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] --json";
|
|
462
|
+
assertSupportedOptions(options, { command: "gmc games follower-history", allowed: ["range", "from", "to"] });
|
|
463
|
+
const appid = assertAppid(positional[0], usage);
|
|
464
|
+
assertNoExtraPositionals(positional, { expected: 1, usage });
|
|
465
|
+
if (options.range != null && (options.from != null || options.to != null)) {
|
|
466
|
+
writeJsonError({ code: "CLI_USAGE_ERROR", message: "--range cannot be combined with --from or --to." });
|
|
467
|
+
process.exit(EXIT_USAGE);
|
|
468
|
+
}
|
|
469
|
+
const range = options.range == null ? undefined : assertEnumQueryOption(options.range, "--range", HISTORY_RANGES);
|
|
470
|
+
if (options.from != null) assertCalendarDate(options.from, "--from");
|
|
471
|
+
if (options.to != null) assertCalendarDate(options.to, "--to");
|
|
472
|
+
if (options.from != null && options.to != null && String(options.from) > String(options.to)) {
|
|
473
|
+
writeJsonError({ code: "CLI_USAGE_ERROR", message: "--from must be on or before --to." });
|
|
474
|
+
process.exit(EXIT_USAGE);
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/follower-history`,
|
|
478
|
+
query: {
|
|
479
|
+
...(range ? { range } : {}),
|
|
480
|
+
...(options.from != null ? { from: String(options.from) } : {}),
|
|
481
|
+
...(options.to != null ? { to: String(options.to) } : {})
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// NAK-934 specimen sheet. include is the route's opt-in list; its only value
|
|
487
|
+
// is rank_series, checked locally so a typo is a usage error.
|
|
488
|
+
if (matches(command, ["games", "events"])) {
|
|
489
|
+
const usage = "gmc games events <appid> [--include rank_series] --json";
|
|
490
|
+
assertSupportedOptions(options, { command: "gmc games events", allowed: ["include"] });
|
|
491
|
+
if (options.repeatedOptions?.has("include")) {
|
|
492
|
+
writeJsonError({
|
|
493
|
+
code: "CLI_USAGE_ERROR",
|
|
494
|
+
message: "--include may be given once; its only value is rank_series",
|
|
495
|
+
usage
|
|
496
|
+
});
|
|
497
|
+
process.exit(EXIT_USAGE);
|
|
498
|
+
}
|
|
499
|
+
const appid = assertAppid(positional[0], usage);
|
|
500
|
+
assertNoExtraPositionals(positional, { expected: 1, usage });
|
|
501
|
+
const include = options.include == null ? undefined : assertEnumQueryOption(options.include, "--include", STEAM_EVENTS_INCLUDE);
|
|
502
|
+
return {
|
|
503
|
+
path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/events`,
|
|
504
|
+
query: include ? { include } : {}
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// NAK-934. No query parameters at all (the ai-disclosure shape), so any
|
|
509
|
+
// flag is a usage error rather than a silently ignored narrowing.
|
|
510
|
+
if (matches(command, ["games", "screenshot-labels"])) {
|
|
511
|
+
const usage = "gmc games screenshot-labels <appid> --json";
|
|
512
|
+
assertSupportedOptions(options, {
|
|
513
|
+
command: "gmc games screenshot-labels",
|
|
514
|
+
allowed: []
|
|
515
|
+
});
|
|
516
|
+
const appid = assertAppid(positional[0], usage);
|
|
517
|
+
assertNoExtraPositionals(positional, { expected: 1, usage });
|
|
518
|
+
return {
|
|
519
|
+
path: `/api/v1/steam/games/${encodeURIComponent(String(appid))}/screenshot-labels`,
|
|
520
|
+
query: {}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
413
524
|
if (matches(command, ["reviews", "patterns"])) {
|
|
414
525
|
assertSource(options.source);
|
|
415
526
|
const isCompare = positional[0] === "compare";
|
|
@@ -781,6 +892,84 @@ export function buildRequest(command, options, positional) {
|
|
|
781
892
|
};
|
|
782
893
|
}
|
|
783
894
|
|
|
895
|
+
// NAK-723. The saved-experiences surface: the only WORKSPACE-SCOPED Roblox
|
|
896
|
+
// commands. The five reads above are anonymous and shared-cacheable; these
|
|
897
|
+
// three carry one workspace's own data and resolve through the same unified
|
|
898
|
+
// auth `gmc lists` uses, so they are modelled on that family rather than on
|
|
899
|
+
// the Roblox read family beside them.
|
|
900
|
+
//
|
|
901
|
+
// THE SCOPE FLAGS BELONG TO `saved` ALONE, and the two writes take none. A
|
|
902
|
+
// `--country` on a save is refused here rather than dropped, matching both
|
|
903
|
+
// the REST allowlist (`SAVED_MUTATION_PARAMS` is empty) and the MCP tool's
|
|
904
|
+
// per-action allowlist: a save that quietly ignored a scope flag would look
|
|
905
|
+
// correct while answering a question the caller did not ask.
|
|
906
|
+
//
|
|
907
|
+
// SAVED MEMBERSHIP IS SCOPE-INDEPENDENT. The flags on `saved` decide which
|
|
908
|
+
// of `in_scope` / `out_of_scope` / `not_tracked` each saved experience is
|
|
909
|
+
// in, never which experiences are listed — so no flag here can add or drop
|
|
910
|
+
// a row, and an `out_of_scope` row is saved and tracked, not missing.
|
|
911
|
+
if (matches(command, ["roblox", "saved"])) {
|
|
912
|
+
assertSupportedOptions(options, {
|
|
913
|
+
command: "gmc roblox saved",
|
|
914
|
+
allowed: ["country", "device", "sort", "as-of", "eligibility-days"]
|
|
915
|
+
});
|
|
916
|
+
assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox saved --json" });
|
|
917
|
+
return {
|
|
918
|
+
path: "/api/v1/roblox/saved",
|
|
919
|
+
// Deliberately no --limit and no --cursor: the saved set is capped per
|
|
920
|
+
// workspace and one response always holds all of it. Accepting either
|
|
921
|
+
// would imply the list can hide part of itself.
|
|
922
|
+
query: pickQuery(options, {
|
|
923
|
+
country: "country",
|
|
924
|
+
device: "device",
|
|
925
|
+
sort: "sort",
|
|
926
|
+
"as-of": "as_of",
|
|
927
|
+
"eligibility-days": "eligibility_days"
|
|
928
|
+
})
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Both writes are IDEMPOTENT and report the state that now holds. A repeat
|
|
933
|
+
// answers `changed: false` with the same body and is success, not a failure
|
|
934
|
+
// to retry — nothing here turns that into a non-zero exit.
|
|
935
|
+
//
|
|
936
|
+
// The id reaches the wire as the decimal STRING `assertUniverseId` returned.
|
|
937
|
+
// Coercing it to a number would both lose precision above 2^53 and trip the
|
|
938
|
+
// server's own `requireSavedUniverseId`, which refuses a JSON number outright
|
|
939
|
+
// because a number is exactly what a caller sends when it has routed a Steam
|
|
940
|
+
// appid into this path.
|
|
941
|
+
if (matches(command, ["roblox", "save"])) {
|
|
942
|
+
assertSupportedOptions(options, { command: "gmc roblox save", allowed: [] });
|
|
943
|
+
const universeId = assertUniverseId(positional[0], "gmc roblox save <universeId> --json");
|
|
944
|
+
assertNoExtraPositionals(positional, {
|
|
945
|
+
expected: 1,
|
|
946
|
+
usage: "gmc roblox save <universeId> --json"
|
|
947
|
+
});
|
|
948
|
+
return {
|
|
949
|
+
path: "/api/v1/roblox/saved",
|
|
950
|
+
method: "POST",
|
|
951
|
+
query: {},
|
|
952
|
+
body: { universeId }
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Removes the id from THIS WORKSPACE's saved set. It does not delete an
|
|
957
|
+
// experience, does not touch collection, and removing something that is not
|
|
958
|
+
// saved is `changed: false` rather than a 404.
|
|
959
|
+
if (matches(command, ["roblox", "remove"])) {
|
|
960
|
+
assertSupportedOptions(options, { command: "gmc roblox remove", allowed: [] });
|
|
961
|
+
const universeId = assertUniverseId(positional[0], "gmc roblox remove <universeId> --json");
|
|
962
|
+
assertNoExtraPositionals(positional, {
|
|
963
|
+
expected: 1,
|
|
964
|
+
usage: "gmc roblox remove <universeId> --json"
|
|
965
|
+
});
|
|
966
|
+
return {
|
|
967
|
+
path: `/api/v1/roblox/saved/${encodeURIComponent(universeId)}`,
|
|
968
|
+
method: "DELETE",
|
|
969
|
+
query: {}
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
784
973
|
return null;
|
|
785
974
|
}
|
|
786
975
|
|
|
@@ -1141,6 +1330,11 @@ function assertSource(source = "steam") {
|
|
|
1141
1330
|
}
|
|
1142
1331
|
}
|
|
1143
1332
|
|
|
1333
|
+
// Mirrors GAME_FOLLOWER_HISTORY_RANGE_PRESETS on the server, which the
|
|
1334
|
+
// price-history endpoint reuses (NAK-934). The server remains the authority.
|
|
1335
|
+
const HISTORY_RANGES = ["7d", "1m", "1y", "all"];
|
|
1336
|
+
const STEAM_EVENTS_INCLUDE = ["rank_series"];
|
|
1337
|
+
|
|
1144
1338
|
function assertDate(value, optionName) {
|
|
1145
1339
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) {
|
|
1146
1340
|
writeJsonError({
|
|
@@ -1151,6 +1345,30 @@ function assertDate(value, optionName) {
|
|
|
1151
1345
|
}
|
|
1152
1346
|
}
|
|
1153
1347
|
|
|
1348
|
+
// `assertDate` plus a real-calendar check, mirroring the server's
|
|
1349
|
+
// `parseCalendarDateParam` (follower-history.ts) so `2026-02-30` is a usage
|
|
1350
|
+
// error here instead of a request. Kept separate from `assertDate`, whose
|
|
1351
|
+
// other caller (--deadline-before) is not in scope for this rule.
|
|
1352
|
+
function assertCalendarDate(value, optionName) {
|
|
1353
|
+
assertDate(value, optionName);
|
|
1354
|
+
const [year, month, day] = String(value).split("-").map(Number);
|
|
1355
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
1356
|
+
// Date.UTC maps years 0-99 into the 1900s; restore the year so the check
|
|
1357
|
+
// is about the DAY being real, as on the server.
|
|
1358
|
+
probe.setUTCFullYear(year);
|
|
1359
|
+
if (
|
|
1360
|
+
probe.getUTCFullYear() !== year ||
|
|
1361
|
+
probe.getUTCMonth() !== month - 1 ||
|
|
1362
|
+
probe.getUTCDate() !== day
|
|
1363
|
+
) {
|
|
1364
|
+
writeJsonError({
|
|
1365
|
+
code: "CLI_USAGE_ERROR",
|
|
1366
|
+
message: `${optionName} must be a real calendar date`
|
|
1367
|
+
});
|
|
1368
|
+
process.exit(EXIT_USAGE);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1154
1372
|
function assertPositiveIntegerOption(value, optionName) {
|
|
1155
1373
|
const number = Number(value);
|
|
1156
1374
|
if (!Number.isInteger(number) || number < 1) {
|
|
@@ -1315,10 +1533,17 @@ const GLOBAL_CLI_OPTIONS = new Set(["json", "help", "version", "fields", "preset
|
|
|
1315
1533
|
function assertSupportedOptions(options, { command, allowed }) {
|
|
1316
1534
|
for (const key of Object.keys(options)) {
|
|
1317
1535
|
if (GLOBAL_CLI_OPTIONS.has(key) || allowed.includes(key)) continue;
|
|
1318
|
-
|
|
1536
|
+
// NAK-723: an empty allowlist rendered as "Supported options: ." — which
|
|
1537
|
+
// reads like a message with its list missing rather than like a command
|
|
1538
|
+
// that takes no options. `gmc roblox save`/`remove` are the second and
|
|
1539
|
+
// third such command (after `gmc games ai-disclosure`), so it is spelled
|
|
1540
|
+
// out here once, in the MCP tool's own wording for the same case.
|
|
1541
|
+
const supported = allowed.length
|
|
1542
|
+
? `Supported options: ${allowed.map((name) => `--${name}`).join(", ")}.`
|
|
1543
|
+
: "This command takes no options.";
|
|
1319
1544
|
writeJsonError({
|
|
1320
1545
|
code: "CLI_USAGE_ERROR",
|
|
1321
|
-
message: `--${key} is not supported by ${command}.
|
|
1546
|
+
message: `--${key} is not supported by ${command}. ${supported} The endpoint does not accept it, so it would be ignored rather than applied.`
|
|
1322
1547
|
});
|
|
1323
1548
|
process.exit(EXIT_USAGE);
|
|
1324
1549
|
}
|
|
@@ -1376,7 +1601,11 @@ function assertAppid(value, usage) {
|
|
|
1376
1601
|
|
|
1377
1602
|
/**
|
|
1378
1603
|
* NAK-715. Validates the universeId positional for the Roblox per-experience
|
|
1379
|
-
* commands, mirroring the path-parameter check the app applies.
|
|
1604
|
+
* commands, mirroring the path-parameter check the app applies. NAK-723 shares
|
|
1605
|
+
* it with `roblox save` / `roblox remove`, where the same string is also the
|
|
1606
|
+
* request BODY's `universeId` — the regex below is character-for-character the
|
|
1607
|
+
* server's own `parseRobloxId`, so the two surfaces accept and reject the same
|
|
1608
|
+
* spellings.
|
|
1380
1609
|
*
|
|
1381
1610
|
* It returns a STRING and never a number, which is the point: a Roblox
|
|
1382
1611
|
* universe id is a 64-bit value, and `Number.parseInt` would round anything
|
|
@@ -1389,6 +1618,14 @@ function assertAppid(value, usage) {
|
|
|
1389
1618
|
*
|
|
1390
1619
|
* A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
|
|
1391
1620
|
* and this value never reaches a Steam route.
|
|
1621
|
+
*
|
|
1622
|
+
* What this CANNOT catch, deliberately: a bare Steam appid like `730` is a
|
|
1623
|
+
* lexically valid universe id, on this side and on the server's. It is
|
|
1624
|
+
* forwarded, and the server's tracked-cohort gate answers NOT_FOUND naming
|
|
1625
|
+
* exactly that — "not in the tracked cohort, so saving it would create a row
|
|
1626
|
+
* nothing could explain". Guessing here from the id's magnitude instead would
|
|
1627
|
+
* invent a rule the server does not have and would reject real small universe
|
|
1628
|
+
* ids, which is a worse failure than the precise 404.
|
|
1392
1629
|
*/
|
|
1393
1630
|
function assertUniverseId(value, usage) {
|
|
1394
1631
|
const raw = value == null ? "" : String(value).trim();
|
package/src/requests.test.js
CHANGED
|
@@ -514,6 +514,111 @@ test("games ai-disclosure is listed as Provisional (NAK-804)", () => {
|
|
|
514
514
|
assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games ai-disclosure <appid> --json"));
|
|
515
515
|
});
|
|
516
516
|
|
|
517
|
+
// --- gmc games price-history / screenshot-labels (NAK-934) ----------------
|
|
518
|
+
//
|
|
519
|
+
// The ai-disclosure route shape: a plain numeric appid, no --source.
|
|
520
|
+
|
|
521
|
+
test("games price-history targets the steam route and leaves the window to the server by default", () => {
|
|
522
|
+
const request = buildRequestFromArgv(["games", "price-history", "730", "--json"]);
|
|
523
|
+
|
|
524
|
+
assert.equal(request.path, "/api/v1/steam/games/730/price-history");
|
|
525
|
+
assert.deepEqual(request.query, {});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("games price-history forwards --range, or --from/--to, verbatim", () => {
|
|
529
|
+
assert.deepEqual(
|
|
530
|
+
buildRequestFromArgv(["games", "price-history", "730", "--range", "1y", "--json"]).query,
|
|
531
|
+
{ range: "1y" }
|
|
532
|
+
);
|
|
533
|
+
assert.deepEqual(
|
|
534
|
+
buildRequestFromArgv([
|
|
535
|
+
"games", "price-history", "730", "--from", "2026-01-01", "--to", "2026-03-31", "--json"
|
|
536
|
+
]).query,
|
|
537
|
+
{ from: "2026-01-01", to: "2026-03-31" }
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("games price-history rejects a bad window before any request", () => {
|
|
542
|
+
for (const [argv, message] of [
|
|
543
|
+
[["--range", "90d"], /--range must be one of: 7d, 1m, 1y, all/],
|
|
544
|
+
[["--range", "1y", "--from", "2026-01-01"], /--range cannot be combined with --from or --to/],
|
|
545
|
+
[["--from", "2026/01/01"], /--from must use YYYY-MM-DD/],
|
|
546
|
+
[["--to", "yesterday"], /--to must use YYYY-MM-DD/],
|
|
547
|
+
// Real calendar dates only, as on the server (NAK-934 review).
|
|
548
|
+
[["--from", "2026-02-30"], /--from must be a real calendar date/],
|
|
549
|
+
[["--to", "2025-02-29"], /--to must be a real calendar date/],
|
|
550
|
+
[["--from", "2026-13-01"], /--from must be a real calendar date/],
|
|
551
|
+
[["--to", "2026-04-00"], /--to must be a real calendar date/],
|
|
552
|
+
// An explicit pair must be ordered.
|
|
553
|
+
[["--from", "2026-05-01", "--to", "2026-04-01"], /--from must be on or before --to/],
|
|
554
|
+
[["--country", "jp"], /--country is not supported by gmc games price-history/],
|
|
555
|
+
[["--source", "steam"], /--source is not supported by gmc games price-history/]
|
|
556
|
+
]) {
|
|
557
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
558
|
+
buildRequestFromArgv(["games", "price-history", "730", ...argv, "--json"])
|
|
559
|
+
);
|
|
560
|
+
assert.equal(exitCode, 2, argv.join(" "));
|
|
561
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
562
|
+
assert.match(output.error.message, message);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("games price-history accepts a leap day, a single-day window and a lone --from", () => {
|
|
567
|
+
assert.deepEqual(
|
|
568
|
+
buildRequestFromArgv(["games", "price-history", "730", "--from", "2024-02-29", "--json"]).query,
|
|
569
|
+
{ from: "2024-02-29" }
|
|
570
|
+
);
|
|
571
|
+
assert.deepEqual(
|
|
572
|
+
buildRequestFromArgv([
|
|
573
|
+
"games", "price-history", "730", "--from", "2026-04-01", "--to", "2026-04-01", "--json"
|
|
574
|
+
]).query,
|
|
575
|
+
{ from: "2026-04-01", to: "2026-04-01" }
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("games price-history rejects a malformed appid and an extra positional", () => {
|
|
580
|
+
for (const argv of [["0x2da"], ["730", "440"], []]) {
|
|
581
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
582
|
+
buildRequestFromArgv(["games", "price-history", ...argv, "--json"])
|
|
583
|
+
);
|
|
584
|
+
assert.equal(exitCode, 2, argv.join(" "));
|
|
585
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("games screenshot-labels targets the steam route and takes no options", () => {
|
|
590
|
+
const request = buildRequestFromArgv(["games", "screenshot-labels", "730", "--fields", "labels", "--json"]);
|
|
591
|
+
assert.equal(request.path, "/api/v1/steam/games/730/screenshot-labels");
|
|
592
|
+
assert.deepEqual(request.query, {});
|
|
593
|
+
|
|
594
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
595
|
+
buildRequestFromArgv(["games", "screenshot-labels", "730", "--limit", "5", "--json"])
|
|
596
|
+
);
|
|
597
|
+
assert.equal(exitCode, 2);
|
|
598
|
+
assert.match(output.error.message, /--limit is not supported by gmc games screenshot-labels/);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("help documents the NAK-934 commands and lists them as Provisional", () => {
|
|
602
|
+
const chunks = [];
|
|
603
|
+
const original = process.stdout.write;
|
|
604
|
+
process.stdout.write = (chunk) => {
|
|
605
|
+
chunks.push(String(chunk));
|
|
606
|
+
return true;
|
|
607
|
+
};
|
|
608
|
+
try {
|
|
609
|
+
printHelp();
|
|
610
|
+
} finally {
|
|
611
|
+
process.stdout.write = original;
|
|
612
|
+
}
|
|
613
|
+
const help = chunks.join("");
|
|
614
|
+
|
|
615
|
+
assert.ok(help.includes("gmc games price-history <appid> [--range <preset>]"));
|
|
616
|
+
assert.ok(help.includes("gmc games screenshot-labels <appid> --json"));
|
|
617
|
+
assert.ok(help.includes("MODEL OUTPUT"));
|
|
618
|
+
assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games price-history <appid> --range 7d|1m|1y|all --json"));
|
|
619
|
+
assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games screenshot-labels <appid> --json"));
|
|
620
|
+
});
|
|
621
|
+
|
|
517
622
|
// --- gmc games language-topics (NAK-455) ---------------------------------
|
|
518
623
|
|
|
519
624
|
test("games language-topics targets the canonical route and sends no invented parameters", () => {
|
|
@@ -1468,3 +1573,272 @@ test("(NAK-715) the roblox commands are published as provisional in --help", ()
|
|
|
1468
1573
|
);
|
|
1469
1574
|
}
|
|
1470
1575
|
});
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* NAK-723: `gmc roblox saved | save | remove`.
|
|
1579
|
+
*
|
|
1580
|
+
* The saved surface is workspace-scoped and MUTATING, so these tests pin what
|
|
1581
|
+
* the read tests above do not have to: which flags belong to the list and not
|
|
1582
|
+
* to the writes, that the two writes carry the right HTTP method and path,
|
|
1583
|
+
* and that the id reaches the BODY as a decimal string rather than a number.
|
|
1584
|
+
*/
|
|
1585
|
+
test("(NAK-723) roblox saved reads the bare endpoint and lets the server pick the scope", () => {
|
|
1586
|
+
assert.deepEqual(buildRequestFromArgv(["roblox", "saved", "--json"]), {
|
|
1587
|
+
path: "/api/v1/roblox/saved",
|
|
1588
|
+
// Empty, not {country: "all"}: a default copied here could drift from the
|
|
1589
|
+
// server's, exactly as on the read family.
|
|
1590
|
+
query: {}
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test("(NAK-723) roblox saved maps every scope flag to its query parameter", () => {
|
|
1595
|
+
const request = buildRequestFromArgv([
|
|
1596
|
+
"roblox", "saved",
|
|
1597
|
+
"--country", "JP",
|
|
1598
|
+
"--device", "computer",
|
|
1599
|
+
"--sort", "top-trending",
|
|
1600
|
+
"--as-of", "2026-09-20T00:00:00.000Z",
|
|
1601
|
+
"--eligibility-days", "30",
|
|
1602
|
+
"--json"
|
|
1603
|
+
]);
|
|
1604
|
+
|
|
1605
|
+
assert.equal(request.path, "/api/v1/roblox/saved");
|
|
1606
|
+
assert.equal(request.method, undefined);
|
|
1607
|
+
assert.deepEqual(request.query, {
|
|
1608
|
+
country: "JP",
|
|
1609
|
+
device: "computer",
|
|
1610
|
+
sort: "top-trending",
|
|
1611
|
+
as_of: "2026-09-20T00:00:00.000Z",
|
|
1612
|
+
eligibility_days: "30"
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* The saved set is capped per workspace and one response always holds all of
|
|
1618
|
+
* it. Accepting a pager would imply the list can hide part of itself, and the
|
|
1619
|
+
* REST allowlist rejects both.
|
|
1620
|
+
*/
|
|
1621
|
+
test("(NAK-723) roblox saved is not paginated and refuses a pager rather than dropping it", () => {
|
|
1622
|
+
for (const flag of ["--limit", "--cursor", "--offset"]) {
|
|
1623
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1624
|
+
buildRequestFromArgv(["roblox", "saved", flag, "10", "--json"])
|
|
1625
|
+
);
|
|
1626
|
+
assert.equal(exitCode, 2, `expected ${flag} to be refused`);
|
|
1627
|
+
assert.match(output.error.message, /is not supported by gmc roblox saved/);
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test("(NAK-723) roblox save posts the id as a decimal string body", () => {
|
|
1632
|
+
const request = buildRequestFromArgv(["roblox", "save", "4924922222", "--json"]);
|
|
1633
|
+
|
|
1634
|
+
assert.equal(request.path, "/api/v1/roblox/saved");
|
|
1635
|
+
assert.equal(request.method, "POST");
|
|
1636
|
+
assert.deepEqual(request.query, {});
|
|
1637
|
+
assert.deepEqual(request.body, { universeId: "4924922222" });
|
|
1638
|
+
// The server refuses a JSON number outright, because a number is what a
|
|
1639
|
+
// caller sends when it has routed a Steam appid into this path.
|
|
1640
|
+
assert.equal(typeof request.body.universeId, "string");
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Above 2^53 a number would round into a DIFFERENT experience's id, and the
|
|
1645
|
+
* body is where that would silently succeed.
|
|
1646
|
+
*/
|
|
1647
|
+
test("(NAK-723) a large universe id keeps every digit through the save body", () => {
|
|
1648
|
+
const request = buildRequestFromArgv(["roblox", "save", "9007199254740993", "--json"]);
|
|
1649
|
+
assert.deepEqual(request.body, { universeId: "9007199254740993" });
|
|
1650
|
+
assert.notEqual(String(Number("9007199254740993")), request.body.universeId);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
test("(NAK-723) roblox remove deletes the per-id path", () => {
|
|
1654
|
+
const request = buildRequestFromArgv(["roblox", "remove", "4924922222", "--json"]);
|
|
1655
|
+
|
|
1656
|
+
assert.equal(request.path, "/api/v1/roblox/saved/4924922222");
|
|
1657
|
+
assert.equal(request.method, "DELETE");
|
|
1658
|
+
assert.deepEqual(request.query, {});
|
|
1659
|
+
assert.equal(request.body, undefined);
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* The scope flags belong to `saved` alone. A save that quietly ignored
|
|
1664
|
+
* --country would look correct while answering a question nobody asked, and
|
|
1665
|
+
* the identical REST request is a 400.
|
|
1666
|
+
*/
|
|
1667
|
+
test("(NAK-723) the writes take no flags, so a scope flag is a usage error", () => {
|
|
1668
|
+
for (const subcommand of ["save", "remove"]) {
|
|
1669
|
+
for (const [flag, value] of [["--country", "JP"], ["--as-of", "2026-09-20T00:00:00.000Z"]]) {
|
|
1670
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1671
|
+
buildRequestFromArgv(["roblox", subcommand, "4924922222", flag, value, "--json"])
|
|
1672
|
+
);
|
|
1673
|
+
assert.equal(exitCode, 2, `expected ${subcommand} ${flag} to be refused`);
|
|
1674
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1675
|
+
assert.match(output.error.message, new RegExp(`is not supported by gmc roblox ${subcommand}`));
|
|
1676
|
+
// A command with an empty allowlist says so, rather than rendering
|
|
1677
|
+
// "Supported options: ." and reading like a truncated message.
|
|
1678
|
+
assert.match(output.error.message, /This command takes no options\./);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* The same lexical contract the read commands enforce, applied before any
|
|
1685
|
+
* request is built: a spelling the server's `parseRobloxId` rejects must not
|
|
1686
|
+
* reach the wire looking fine, and the message must name the Steam-appid
|
|
1687
|
+
* confusion rather than only "invalid id".
|
|
1688
|
+
*/
|
|
1689
|
+
test("(NAK-723) a malformed universe id fails fast on both writes", () => {
|
|
1690
|
+
for (const subcommand of ["save", "remove"]) {
|
|
1691
|
+
for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
|
|
1692
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1693
|
+
buildRequestFromArgv(["roblox", subcommand, bad, "--json"])
|
|
1694
|
+
);
|
|
1695
|
+
assert.equal(exitCode, 2, `expected ${subcommand} ${bad} to be refused`);
|
|
1696
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1697
|
+
assert.match(output.error.message, /not a Steam appid/);
|
|
1698
|
+
assert.match(output.error.message, new RegExp(`gmc roblox ${subcommand} <universeId>`));
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", subcommand, "--json"]));
|
|
1702
|
+
assert.equal(missing.exitCode, 2, `expected a missing id on ${subcommand} to be refused`);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* A bare Steam appid is a LEXICALLY valid universe id on both sides, so it is
|
|
1708
|
+
* forwarded and the server's tracked-cohort gate answers NOT_FOUND naming that
|
|
1709
|
+
* fact. Guessing from the id's magnitude here would invent a rule the server
|
|
1710
|
+
* does not have and would reject real small universe ids.
|
|
1711
|
+
*/
|
|
1712
|
+
test("(NAK-723) a lexically valid small id is forwarded for the server's cohort gate to judge", () => {
|
|
1713
|
+
assert.deepEqual(buildRequestFromArgv(["roblox", "save", "730", "--json"]).body, {
|
|
1714
|
+
universeId: "730"
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
test("(NAK-723) a stray positional on a write is a usage error, not a second id", () => {
|
|
1719
|
+
for (const subcommand of ["save", "remove"]) {
|
|
1720
|
+
const { exitCode } = captureUsageFailure(() =>
|
|
1721
|
+
buildRequestFromArgv(["roblox", subcommand, "4924922222", "4924922223", "--json"])
|
|
1722
|
+
);
|
|
1723
|
+
assert.equal(exitCode, 2, `expected a second id on ${subcommand} to be refused`);
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* A token missing from the args.js whitelist falls through to positional, so
|
|
1729
|
+
* `gmc roblox save <id>` would parse as the unknown command `roblox` — the same
|
|
1730
|
+
* trap `lists materialize` documents, and worse here because both are writes.
|
|
1731
|
+
*/
|
|
1732
|
+
test("(NAK-723) saved, save and remove are real command tokens, not positionals", () => {
|
|
1733
|
+
for (const subcommand of ["saved", "save", "remove"]) {
|
|
1734
|
+
const { command, positional } = parseArgs(["roblox", subcommand, "4924922222", "--json"]);
|
|
1735
|
+
assert.deepEqual(command, ["roblox", subcommand]);
|
|
1736
|
+
// The id is the command's own positional, so the branch above receives it
|
|
1737
|
+
// rather than the subcommand name landing here as a stray argument.
|
|
1738
|
+
assert.deepEqual(positional, ["4924922222"]);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// And `saved` takes none, so the same token there is an extra argument
|
|
1742
|
+
// rather than a silently ignored one.
|
|
1743
|
+
assert.equal(
|
|
1744
|
+
captureUsageFailure(() => buildRequestFromArgv(["roblox", "saved", "4924922222", "--json"])).exitCode,
|
|
1745
|
+
2
|
|
1746
|
+
);
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
test("(NAK-723) the saved commands are published as provisional in --help", () => {
|
|
1750
|
+
for (const command of [
|
|
1751
|
+
"gmc roblox saved --country all --json",
|
|
1752
|
+
"gmc roblox save <universeId> --json",
|
|
1753
|
+
"gmc roblox remove <universeId> --json"
|
|
1754
|
+
]) {
|
|
1755
|
+
assert.ok(
|
|
1756
|
+
PROVISIONAL_BETA_COMMANDS.includes(command),
|
|
1757
|
+
`${command} must stay listed as provisional`
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const chunks = [];
|
|
1762
|
+
const original = process.stdout.write;
|
|
1763
|
+
process.stdout.write = (chunk) => {
|
|
1764
|
+
chunks.push(String(chunk));
|
|
1765
|
+
return true;
|
|
1766
|
+
};
|
|
1767
|
+
try {
|
|
1768
|
+
printHelp();
|
|
1769
|
+
} finally {
|
|
1770
|
+
process.stdout.write = original;
|
|
1771
|
+
}
|
|
1772
|
+
const help = chunks.join("");
|
|
1773
|
+
|
|
1774
|
+
assert.ok(help.includes("gmc roblox saved --country all --json"));
|
|
1775
|
+
assert.ok(help.includes("gmc roblox save <universeId> --json"));
|
|
1776
|
+
assert.ok(help.includes("gmc roblox remove <universeId> --json"));
|
|
1777
|
+
// The two facts an agent most needs from the help text, and the two most
|
|
1778
|
+
// expensive to get wrong: a repeat write is success, and an out-of-scope row
|
|
1779
|
+
// is still saved.
|
|
1780
|
+
assert.match(help, /changed: false is success/);
|
|
1781
|
+
assert.match(help, /out_of_scope is saved and\s+tracked/);
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
// --- gmc games follower-history / events (NAK-934) -------------------------
|
|
1785
|
+
|
|
1786
|
+
test("games follower-history targets the steam route and leaves the window to the server by default", () => {
|
|
1787
|
+
const request = buildRequestFromArgv(["games", "follower-history", "2436940", "--json"]);
|
|
1788
|
+
assert.equal(request.path, "/api/v1/steam/games/2436940/follower-history");
|
|
1789
|
+
assert.deepEqual(request.query, {});
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
test("games follower-history passes a preset or an explicit window", () => {
|
|
1793
|
+
assert.deepEqual(buildRequestFromArgv(["games", "follower-history", "730", "--range", "1y", "--json"]).query, { range: "1y" });
|
|
1794
|
+
assert.deepEqual(
|
|
1795
|
+
buildRequestFromArgv(["games", "follower-history", "730", "--from", "2026-01-01", "--to", "2026-06-30", "--json"]).query,
|
|
1796
|
+
{ from: "2026-01-01", to: "2026-06-30" }
|
|
1797
|
+
);
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
test("games follower-history rejects a bad window before any request", () => {
|
|
1801
|
+
for (const [argv, message] of [
|
|
1802
|
+
[["--range", "1y", "--from", "2026-01-01"], /--range cannot be combined with --from or --to/],
|
|
1803
|
+
[["--range", "90d"], /--range must be one of: 7d, 1m, 1y, all/],
|
|
1804
|
+
[["--from", "2026/01/01"], /--from must use YYYY-MM-DD/],
|
|
1805
|
+
[["--from", "2026-02-30"], /--from must be a real calendar date/],
|
|
1806
|
+
[["--from", "2026-05-01", "--to", "2026-04-01"], /--from must be on or before --to/],
|
|
1807
|
+
[["--country", "jp"], /--country is not supported by gmc games follower-history/]
|
|
1808
|
+
]) {
|
|
1809
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1810
|
+
buildRequestFromArgv(["games", "follower-history", "730", ...argv, "--json"])
|
|
1811
|
+
);
|
|
1812
|
+
assert.equal(exitCode, 2, argv.join(" "));
|
|
1813
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1814
|
+
assert.match(output.error.message, message);
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test("games events targets the steam route, with rank_series opt-in", () => {
|
|
1819
|
+
assert.equal(buildRequestFromArgv(["games", "events", "3364220", "--json"]).path, "/api/v1/steam/games/3364220/events");
|
|
1820
|
+
assert.deepEqual(buildRequestFromArgv(["games", "events", "3364220", "--json"]).query, {});
|
|
1821
|
+
assert.deepEqual(buildRequestFromArgv(["games", "events", "3364220", "--include", "rank_series", "--json"]).query, { include: "rank_series" });
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
test("games events rejects an unknown include value and unsupported flags", () => {
|
|
1825
|
+
for (const [argv, message] of [
|
|
1826
|
+
[["--include", "ranks"], /--include must be one of: rank_series/],
|
|
1827
|
+
[["--include", "bad", "--include", "rank_series"], /--include may be given once; its only value is rank_series/],
|
|
1828
|
+
[["--include", "rank_series", "--include=rank_series"], /--include may be given once/],
|
|
1829
|
+
[["--range", "1y"], /--range is not supported by gmc games events/]
|
|
1830
|
+
]) {
|
|
1831
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1832
|
+
buildRequestFromArgv(["games", "events", "730", ...argv, "--json"])
|
|
1833
|
+
);
|
|
1834
|
+
assert.equal(exitCode, 2, argv.join(" "));
|
|
1835
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1836
|
+
assert.match(output.error.message, message);
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
test("the new commands are listed and documented", async () => {
|
|
1841
|
+
const { PROVISIONAL_BETA_COMMANDS } = await import("./constants.js");
|
|
1842
|
+
assert.ok(PROVISIONAL_BETA_COMMANDS.some((command) => command.startsWith("gmc games follower-history ")));
|
|
1843
|
+
assert.ok(PROVISIONAL_BETA_COMMANDS.some((command) => command.startsWith("gmc games events ")));
|
|
1844
|
+
});
|