Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ game-pass-api
By default it reads `./config.json`; pass `--config <path>` 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.

Expand Down
23 changes: 19 additions & 4 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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>', 'path to a config.json (defaults to ./config.json)')
.option('--from <dir>', 're-format previously-saved completeGameProperties_*.json files in <dir> instead of fetching (needs an earlier run with keepCompleteProperties)')
.option('-o, --out <dir>', '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 <path>).\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 <codes>', 'comma-separated market codes to fetch, e.g. US,DE (flag-driven mode)')
.option('--platforms <list>', 'comma-separated platforms: console,pc,eaPlay (flag-driven mode)')
.option('--language <code>', 'language/locale for game properties, e.g. en-us (flag-driven mode)')
.option('--format <format>', 'output format: array, productTitle, productId or 0-indexed (flag-driven mode)')
.option('--properties <list>', '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');
Expand Down
69 changes: 69 additions & 0 deletions js/cliConfig.js
Original file line number Diff line number Diff line change
@@ -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;
}
68 changes: 68 additions & 0 deletions test/cliConfig.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});