@witchpot/gmc 0.9.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 +82 -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 +30 -1
- package/src/constants.js +23 -2
- package/src/help.js +180 -0
- package/src/requests.js +398 -2
- package/src/requests.test.js +545 -0
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";
|
|
@@ -650,6 +761,215 @@ export function buildRequest(command, options, positional) {
|
|
|
650
761
|
};
|
|
651
762
|
}
|
|
652
763
|
|
|
764
|
+
// NAK-715. Thin pass-throughs to the Roblox read endpoints, on the same rule
|
|
765
|
+
// the tag-trends branch above documents: the CLI forwards the flags it was
|
|
766
|
+
// given and the SERVER owns the contract — the per-endpoint allowlists, the
|
|
767
|
+
// enum members, the limit ceilings, the window bounds and the cursor
|
|
768
|
+
// binding. `assertSupportedOptions` still runs for the narrower thing it is
|
|
769
|
+
// for: a flag the endpoint has no parameter for would otherwise be dropped
|
|
770
|
+
// silently and answer the unnarrowed question with exit 0.
|
|
771
|
+
//
|
|
772
|
+
// A Roblox universe id is NOT a Steam appid and is never sent as a number.
|
|
773
|
+
// It travels as the decimal string the user typed, because these ids exceed
|
|
774
|
+
// the range a JS number represents exactly.
|
|
775
|
+
if (matches(command, ["roblox", "charts"])) {
|
|
776
|
+
assertSupportedOptions(options, {
|
|
777
|
+
command: "gmc roblox charts",
|
|
778
|
+
allowed: ["country", "device", "sort", "as-of", "limit", "offset"]
|
|
779
|
+
});
|
|
780
|
+
assertNoExtraPositionals(positional, {
|
|
781
|
+
expected: 0,
|
|
782
|
+
usage: "gmc roblox charts --country all --sort top-playing-now --json"
|
|
783
|
+
});
|
|
784
|
+
return {
|
|
785
|
+
path: "/api/v1/roblox/charts",
|
|
786
|
+
// Every value is omitted when unset so the server owns each default and
|
|
787
|
+
// no default can drift into a copy kept here.
|
|
788
|
+
query: pickQuery(options, {
|
|
789
|
+
country: "country",
|
|
790
|
+
device: "device",
|
|
791
|
+
sort: "sort",
|
|
792
|
+
"as-of": "as_of",
|
|
793
|
+
limit: "limit",
|
|
794
|
+
offset: "offset"
|
|
795
|
+
})
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (matches(command, ["roblox", "experiences"])) {
|
|
800
|
+
assertSupportedOptions(options, {
|
|
801
|
+
command: "gmc roblox experiences",
|
|
802
|
+
allowed: [
|
|
803
|
+
"country",
|
|
804
|
+
"device",
|
|
805
|
+
"sort",
|
|
806
|
+
"as-of",
|
|
807
|
+
"eligibility-days",
|
|
808
|
+
"min-player-count",
|
|
809
|
+
"max-rank",
|
|
810
|
+
"genre",
|
|
811
|
+
"name",
|
|
812
|
+
"creator-type",
|
|
813
|
+
"detail",
|
|
814
|
+
"order-by",
|
|
815
|
+
"order",
|
|
816
|
+
"limit",
|
|
817
|
+
"cursor"
|
|
818
|
+
]
|
|
819
|
+
});
|
|
820
|
+
assertNoExtraPositionals(positional, {
|
|
821
|
+
expected: 0,
|
|
822
|
+
usage: "gmc roblox experiences --country all --limit 50 --json"
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
path: "/api/v1/roblox/experiences",
|
|
826
|
+
query: pickQuery(options, {
|
|
827
|
+
country: "country",
|
|
828
|
+
device: "device",
|
|
829
|
+
sort: "sort",
|
|
830
|
+
"as-of": "as_of",
|
|
831
|
+
"eligibility-days": "eligibility_days",
|
|
832
|
+
"min-player-count": "min_player_count",
|
|
833
|
+
"max-rank": "max_rank",
|
|
834
|
+
genre: "genre",
|
|
835
|
+
name: "name",
|
|
836
|
+
"creator-type": "creator_type",
|
|
837
|
+
detail: "detail",
|
|
838
|
+
"order-by": "order_by",
|
|
839
|
+
order: "order",
|
|
840
|
+
limit: "limit",
|
|
841
|
+
cursor: "cursor"
|
|
842
|
+
})
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (matches(command, ["roblox", "experience"])) {
|
|
847
|
+
assertSupportedOptions(options, { command: "gmc roblox experience", allowed: ["as-of"] });
|
|
848
|
+
const universeId = assertUniverseId(
|
|
849
|
+
positional[0],
|
|
850
|
+
"gmc roblox experience <universeId> --json"
|
|
851
|
+
);
|
|
852
|
+
assertNoExtraPositionals(positional, {
|
|
853
|
+
expected: 1,
|
|
854
|
+
usage: "gmc roblox experience <universeId> --json"
|
|
855
|
+
});
|
|
856
|
+
return {
|
|
857
|
+
path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}`,
|
|
858
|
+
query: pickQuery(options, { "as-of": "as_of" })
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (matches(command, ["roblox", "history"])) {
|
|
863
|
+
assertSupportedOptions(options, {
|
|
864
|
+
command: "gmc roblox history",
|
|
865
|
+
allowed: ["granularity", "from", "to", "as-of"]
|
|
866
|
+
});
|
|
867
|
+
const universeId = assertUniverseId(
|
|
868
|
+
positional[0],
|
|
869
|
+
"gmc roblox history <universeId> --granularity daily --json"
|
|
870
|
+
);
|
|
871
|
+
assertNoExtraPositionals(positional, {
|
|
872
|
+
expected: 1,
|
|
873
|
+
usage: "gmc roblox history <universeId> --granularity daily --json"
|
|
874
|
+
});
|
|
875
|
+
return {
|
|
876
|
+
path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}/history`,
|
|
877
|
+
query: pickQuery(options, {
|
|
878
|
+
granularity: "granularity",
|
|
879
|
+
from: "from",
|
|
880
|
+
to: "to",
|
|
881
|
+
"as-of": "as_of"
|
|
882
|
+
})
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (matches(command, ["roblox", "coverage"])) {
|
|
887
|
+
assertSupportedOptions(options, { command: "gmc roblox coverage", allowed: ["as-of"] });
|
|
888
|
+
assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox coverage --json" });
|
|
889
|
+
return {
|
|
890
|
+
path: "/api/v1/roblox/coverage",
|
|
891
|
+
query: pickQuery(options, { "as-of": "as_of" })
|
|
892
|
+
};
|
|
893
|
+
}
|
|
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
|
+
|
|
653
973
|
return null;
|
|
654
974
|
}
|
|
655
975
|
|
|
@@ -1010,6 +1330,11 @@ function assertSource(source = "steam") {
|
|
|
1010
1330
|
}
|
|
1011
1331
|
}
|
|
1012
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
|
+
|
|
1013
1338
|
function assertDate(value, optionName) {
|
|
1014
1339
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) {
|
|
1015
1340
|
writeJsonError({
|
|
@@ -1020,6 +1345,30 @@ function assertDate(value, optionName) {
|
|
|
1020
1345
|
}
|
|
1021
1346
|
}
|
|
1022
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
|
+
|
|
1023
1372
|
function assertPositiveIntegerOption(value, optionName) {
|
|
1024
1373
|
const number = Number(value);
|
|
1025
1374
|
if (!Number.isInteger(number) || number < 1) {
|
|
@@ -1184,10 +1533,17 @@ const GLOBAL_CLI_OPTIONS = new Set(["json", "help", "version", "fields", "preset
|
|
|
1184
1533
|
function assertSupportedOptions(options, { command, allowed }) {
|
|
1185
1534
|
for (const key of Object.keys(options)) {
|
|
1186
1535
|
if (GLOBAL_CLI_OPTIONS.has(key) || allowed.includes(key)) continue;
|
|
1187
|
-
|
|
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.";
|
|
1188
1544
|
writeJsonError({
|
|
1189
1545
|
code: "CLI_USAGE_ERROR",
|
|
1190
|
-
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.`
|
|
1191
1547
|
});
|
|
1192
1548
|
process.exit(EXIT_USAGE);
|
|
1193
1549
|
}
|
|
@@ -1243,6 +1599,46 @@ function assertAppid(value, usage) {
|
|
|
1243
1599
|
return appid;
|
|
1244
1600
|
}
|
|
1245
1601
|
|
|
1602
|
+
/**
|
|
1603
|
+
* NAK-715. Validates the universeId positional for the Roblox per-experience
|
|
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.
|
|
1609
|
+
*
|
|
1610
|
+
* It returns a STRING and never a number, which is the point: a Roblox
|
|
1611
|
+
* universe id is a 64-bit value, and `Number.parseInt` would round anything
|
|
1612
|
+
* above 2^53 into a different experience's id. The lexical test is
|
|
1613
|
+
* `/^[1-9][0-9]*$/` for the same reason `assertAppid` above uses a lexical
|
|
1614
|
+
* test — `Number` also accepts the exponent, hex and decimal-point spellings
|
|
1615
|
+
* the app's validator rejects, and leading zeros would make one experience
|
|
1616
|
+
* reachable under two spellings. This check is advisory; the server remains
|
|
1617
|
+
* the enforcing authority.
|
|
1618
|
+
*
|
|
1619
|
+
* A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
|
|
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.
|
|
1629
|
+
*/
|
|
1630
|
+
function assertUniverseId(value, usage) {
|
|
1631
|
+
const raw = value == null ? "" : String(value).trim();
|
|
1632
|
+
if (!/^[1-9][0-9]{0,18}$/.test(raw)) {
|
|
1633
|
+
writeJsonError({
|
|
1634
|
+
code: "CLI_USAGE_ERROR",
|
|
1635
|
+
message: `Missing or invalid universeId (must be a positive decimal Roblox universe id, not a Steam appid). Usage: ${usage}`
|
|
1636
|
+
});
|
|
1637
|
+
process.exit(EXIT_USAGE);
|
|
1638
|
+
}
|
|
1639
|
+
return raw;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1246
1642
|
function assertResolveQuery(value) {
|
|
1247
1643
|
if (value == null || String(value).trim() === "") {
|
|
1248
1644
|
writeJsonError({
|