From 49b2dfb09fc3bc2c85afd775f6696f67d88be242 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:33:08 +0100 Subject: [PATCH] Support running entirely from CLI flags (--markets, --platforms, ...) without a config file --- README.md | 9 ++++++ bin/cli.js | 23 +++++++++++--- js/cliConfig.js | 69 +++++++++++++++++++++++++++++++++++++++++ test/cliConfig.test.mjs | 68 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 js/cliConfig.js create mode 100644 test/cliConfig.test.mjs diff --git a/README.md b/README.md index ae71c81..6ed79c9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ game-pass-api By default it reads `./config.json`; pass `--config ` to point at a different file. Run `game-pass-api --help` to see every command. +You can also run without a `config.json` at all, building the configuration entirely from flags: + +```bash +game-pass-api --markets US,DE --platforms console,pc --properties productTitle,productId --format productTitle +``` + +Any option you omit uses its default. +Nested options such as images, pricing and user ratings are only available through a `config.json` or the wizard. + > Configuration files are validated against a JSON schema (`config.schema.json`, shipped with the package). > Add `"$schema": "config.schema.json"` to your `config.json`, with a copy of the schema next to it, and your editor will flag mistakes as you type. diff --git a/bin/cli.js b/bin/cli.js index ec92229..bbdb74b 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -5,9 +5,10 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { Command } from 'commander'; -import { loadConfig } from '../js/utils.js'; +import { loadConfig, validateConfig } from '../js/utils.js'; import { run } from '../js/gamePass.js'; import { runWizard } from '../js/wizard.js'; +import { buildConfig, usedBuildingFlags } from '../js/cliConfig.js'; const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); @@ -21,12 +22,26 @@ program program .command('run', { isDefault: true }) - .description('Fetch Game Pass data using a configuration file') + .description('Fetch Game Pass data using a config file, or entirely from flags') .option('-c, --config ', 'path to a config.json (defaults to ./config.json)') .option('--from ', 're-format previously-saved completeGameProperties_*.json files in instead of fetching (needs an earlier run with keepCompleteProperties)') .option('-o, --out ', 'directory to write output files to (overrides outputDirectory; default: output)') - .addHelpText('after', '\nThe configuration comes from a config.json in the current directory (or --config ).\nRun "game-pass-api init" to create one interactively, or see the README and config.schema.json for every option.') - .action(async (options) => { + .option('--markets ', 'comma-separated market codes to fetch, e.g. US,DE (flag-driven mode)') + .option('--platforms ', 'comma-separated platforms: console,pc,eaPlay (flag-driven mode)') + .option('--language ', 'language/locale for game properties, e.g. en-us (flag-driven mode)') + .option('--format ', 'output format: array, productTitle, productId or 0-indexed (flag-driven mode)') + .option('--properties ', 'comma-separated properties to include: productTitle,productId,developerName,publisherName,categories,storePage (flag-driven mode)') + .option('--keep-complete', 'also keep the complete, unfiltered API response per platform and market (flag-driven mode)') + .option('--no-treat-empty-as-null', 'keep empty strings instead of converting them to null (flag-driven mode)') + .addHelpText('after', '\nProvide a config.json (in the current directory or via --config), or build one from flags with --markets/--platforms/--properties etc. (unspecified options use their defaults).\nRun "game-pass-api init" to create a config interactively, or see the README and config.schema.json for every option.') + .action(async (options, command) => { + // Build the config entirely from flags when config-building flags are used (and no explicit --config file) + if (!options.config && usedBuildingFlags(command)) { + const config = buildConfig(options); + validateConfig(config); + await run(config, { fromDirectory: options.from }); + return; + } // Start the interactive wizard when invoked with no options and no config to load, but only in an interactive shell so scripts still get the friendly no-config error if (!options.config && !options.from && !options.out && !fs.existsSync('config.json') && process.stdin.isTTY) { await runWizard('config.json'); diff --git a/js/cliConfig.js b/js/cliConfig.js new file mode 100644 index 0000000..0906163 --- /dev/null +++ b/js/cliConfig.js @@ -0,0 +1,69 @@ +// Pure builders that turn parsed CLI options into a configuration object +// Kept separate from bin/cli.js so they can be unit tested, and they throw on invalid input so the CLI can report the error and exit + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const schema = JSON.parse(fs.readFileSync(path.join(packageDir, 'config.schema.json'), 'utf8').replace(/^\uFEFF/, '')); + +export const MARKETS = schema.properties.markets.items.enum; +export const LANGUAGES = schema.properties.language.enum; +export const PLATFORMS = schema.properties.platformsToFetch.items.enum; +export const OUTPUT_FORMATS = schema.properties.outputFormat.oneOf.map((option) => option.const); +// Only the flat, boolean includedProperties are settable via flags; the nested ones (images, pricing, ...) stay in the config or the wizard +export const BOOLEAN_PROPERTIES = Object.entries(schema.properties.includedProperties.properties) + .filter(([, definition]) => definition.type === 'boolean') + .map(([name]) => name); + +function parseList(value) { + return String(value).split(',').map((entry) => entry.trim()).filter(Boolean); +} + +function normalizeEnumValue(value, allowed, label) { + const match = allowed.find((candidate) => candidate.toLowerCase() === value.toLowerCase()); + if (!match) { + const hint = allowed.length <= 10 ? `Valid values: ${allowed.join(', ')}.` : 'See the README for the list of valid values.'; + throw new Error(`invalid ${label} "${value}". ${hint}`); + } + return match; +} + +function parseEnumList(value, allowed, label) { + const list = parseList(value).map((entry) => normalizeEnumValue(entry, allowed, label)); + if (list.length === 0) { + throw new Error(`provide at least one ${label}.`); + } + return [...new Set(list)]; +} + +// True if any config-building flag was provided on the command line (so we build from flags instead of a config file) +export function usedBuildingFlags(command) { + return ['markets', 'platforms', 'language', 'format', 'properties', 'treatEmptyAsNull', 'keepComplete'] + .some((name) => command.getOptionValueSource(name) === 'cli'); +} + +// Build a full configuration object from parsed CLI options, defaulting anything not provided +export function buildConfig(options) { + const includedProperties = {}; + const properties = options.properties ? parseEnumList(options.properties, BOOLEAN_PROPERTIES, 'property') : ['productTitle']; + for (const property of properties) { + includedProperties[property] = true; + } + + const config = { + $schema: 'config.schema.json', + markets: options.markets ? parseEnumList(options.markets, MARKETS, 'market code') : ['US'], + language: options.language ? normalizeEnumValue(options.language.trim(), LANGUAGES, 'language') : 'en-us', + platformsToFetch: options.platforms ? parseEnumList(options.platforms, PLATFORMS, 'platform') : ['console', 'pc', 'eaPlay'], + outputFormat: options.format ? normalizeEnumValue(options.format.trim(), OUTPUT_FORMATS, 'output format') : 'array', + treatEmptyStringsAsNull: options.treatEmptyAsNull ?? true, + keepCompleteProperties: options.keepComplete ?? false, + includedProperties + }; + if (options.out) { + config.outputDirectory = options.out; + } + return config; +} diff --git a/test/cliConfig.test.mjs b/test/cliConfig.test.mjs new file mode 100644 index 0000000..b5df786 --- /dev/null +++ b/test/cliConfig.test.mjs @@ -0,0 +1,68 @@ +// Description: Offline tests for the flag-driven config builder (js/cliConfig.js) and the CLI flag-driven path + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildConfig, BOOLEAN_PROPERTIES } from '../js/cliConfig.js'; +import { validateConfigResult } from '../js/utils.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const cli = path.join(here, '..', 'bin', 'cli.js'); + +describe('buildConfig', () => { + it('builds a schema-valid config from all defaults', () => { + const config = buildConfig({}); + assert.deepEqual(config.markets, ['US']); + assert.equal(config.language, 'en-us'); + assert.deepEqual(config.platformsToFetch, ['console', 'pc', 'eaPlay']); + assert.equal(config.outputFormat, 'array'); + assert.deepEqual(config.includedProperties, { productTitle: true }); + assert.equal(config.treatEmptyStringsAsNull, true); + assert.equal(config.keepCompleteProperties, false); + assert.equal(validateConfigResult(config).errors.length, 0); + }); + + it('parses and normalizes markets, platforms, language, format and properties', () => { + const config = buildConfig({ markets: 'us,de', platforms: 'console,eaplay', language: 'DE-DE', format: 'productTitle', properties: 'productId,storePage' }); + assert.deepEqual(config.markets, ['US', 'DE']); + assert.deepEqual(config.platformsToFetch, ['console', 'eaPlay']); + assert.equal(config.language, 'de-de'); + assert.equal(config.outputFormat, 'productTitle'); + assert.deepEqual(config.includedProperties, { productId: true, storePage: true }); + assert.equal(validateConfigResult(config).errors.length, 0); + }); + + it('deduplicates repeated market codes', () => { + assert.deepEqual(buildConfig({ markets: 'US,us,DE' }).markets, ['US', 'DE']); + }); + + it('applies --keep-complete, --no-treat-empty-as-null and --out', () => { + const config = buildConfig({ keepComplete: true, treatEmptyAsNull: false, out: 'custom' }); + assert.equal(config.keepCompleteProperties, true); + assert.equal(config.treatEmptyStringsAsNull, false); + assert.equal(config.outputDirectory, 'custom'); + }); + + it('throws on an invalid market, platform, language, format or property', () => { + assert.throws(() => buildConfig({ markets: 'XX' }), /invalid market code/); + assert.throws(() => buildConfig({ platforms: 'switch' }), /invalid platform/); + assert.throws(() => buildConfig({ language: 'xx-yy' }), /invalid language/); + assert.throws(() => buildConfig({ format: 'nope' }), /invalid output format/); + assert.throws(() => buildConfig({ properties: 'bogus' }), /invalid property/); + }); + + it('only exposes the flat boolean includedProperties as flags', () => { + assert.deepEqual([...BOOLEAN_PROPERTIES].sort(), ['categories', 'developerName', 'productId', 'productTitle', 'publisherName', 'storePage']); + }); +}); + +describe('CLI flag-driven mode', () => { + it('reports an invalid market from flags before fetching', () => { + const result = spawnSync(process.execPath, [cli, '--markets', 'XX'], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match((result.stdout ?? '') + (result.stderr ?? ''), /invalid market code/); + }); +});