diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e8aa45 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + validate: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node-version: ['20.7.0', '24'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: yarn + - name: Install dependencies + run: yarn --frozen-lockfile + - name: Validate + run: yarn validate diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 1a313ec..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Lint - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Build and Test - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: yarn - - name: yarn - run: | - yarn diff --git a/README.md b/README.md index 446c126..2432ef5 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,132 @@ # poki-cli + [![npm](https://img.shields.io/npm/v/@poki/cli.svg?style=flat-square)](https://www.npmjs.com/package/@poki/cli) [![node](https://img.shields.io/node/v/@poki/cli.svg?style=flat-square)](https://nodejs.org/) [![license](https://img.shields.io/github/license/poki/poki-cli.svg?style=flat-square)](LICENSE) -The [Poki for Developers](https://developers.poki.com/) command line utility allows you to upload game builds directly from your terminal or CI-pipeline. +The [Poki for Developers](https://developers.poki.com/) CLI is designed primarily for LLMs. Humans only need to install it, configure the current game project, and complete browser authentication. The CLI itself contains the structured command documentation, field references, examples, permissions, and safety information an LLM needs. + +## Install + +Node.js 20.7 or newer is required. Choose one persistent [npm installation mode](https://docs.npmjs.com/cli/install/). -## Installation +Install globally when the CLI should be available to the current user in every project: -You can run it directly the command using `npx`: ```sh -npx @poki/cli --help +npm install --global --ignore-scripts @poki/cli +poki --version ``` -Or you can add this to your project's `package.json`: -```json -{ - "scripts": { - "poki-upload": "poki upload" - }, - "devDependencies": { - "@poki/cli": "*" - } -} +Invoke this installation as `poki`. + +Install as a project-local development dependency when the project should pin and share its CLI version: + +```sh +npm install --save-dev --ignore-scripts @poki/cli +npx @poki/cli --version ``` -And then run `npm install` or `yarn install` to install the dependency. +Invoke this installation as `npx @poki/cli`. Installing or updating this mode modifies the project's `package.json` and npm lockfile, so review and commit those changes with the project. -## Configuration +## Configure the project + +From the game project directory, run: -Before you can upload a build you will need to configure your game ID using the following command: ```sh -npx @poki/cli init --game c7bfd2ba-e23b-486f-9504-a6f196cb44df --build-dir dist +npx @poki/cli init --game GAME_ID --build-dir dist ``` -Replace `c7bfd2ba-e23b-486f-9504-a6f196cb44df` with your game ID (can be found in the address bar on your game page on https://developers.poki.com/). -And replace `dist` with your build directory. This is the directory that will be uploaded to Poki for Developers. +`GAME_ID` is the game ID shown on its Poki for Developers page. `build-dir` is the directory containing the built game. This creates `poki.json`: -This will create a `poki.json` file in the root of your project containing the following: ```json { - "game_id": "c7bfd2ba-e23b-486f-9504-a6f196cb44df", + "game_id": "GAME_ID", "build_dir": "dist" } ``` -Alternatively you can add this to your `package.json`: +Use `--force` if an existing `poki.json` should be replaced. + +The same configuration can instead be stored in `package.json`: + ```json { "poki": { - "game_id": "c7bfd2ba-e23b-486f-9504-a6f196cb44df", + "game_id": "GAME_ID", "build_dir": "dist" } } ``` -## Uploading a build +When both exist, `poki.json` takes precedence. Run the CLI from the configured project directory. -To upload a new build you can simply run: -```sh -npx @poki/cli upload --name "$(git rev-parse --short HEAD)" --notes "$(git log -1 --pretty=%B)" +## Log in + +Authenticate explicitly once: -# Or if you've configured the scripts in the package.json using npm: -npm run-script poki-upload -# Using yarn -yarn poki-upload +```sh +npx @poki/cli auth login ``` -Do make sure your game is built correctly in the configured build_dir. -When using the upload command for the first time your browser will be opened and you'll be asked to authenticate. -The authentication credentials will be stored in a `$XDG_CONFIG_HOME/poki/auth.json`, `$HOME/.config/poki/auth.json` or `%LOCALAPPDATA%\Poki\auth.json`. +This opens the Poki sign-in flow in a browser and saves OAuth credentials locally. Normal API and analytics commands never open a browser automatically. The only exception is the deprecated legacy `upload` command, which preserves its pre-existing implicit browser-login behavior for backwards compatibility. + +## Upgrading from 0.1.x + +`init` and the deprecated top-level `upload` command keep working as before, with one deliberate change: `poki upload` now exits non-zero when the archive or the upload fails. In 0.1.x it logged the failure and still exited `0`, so a pipeline could not tell a published build from a lost one. The human output is unchanged; a structured error document is appended to stderr after it. + +Automated pipelines should move to `poki versions upload`, which reports structured results on stdout and supports `--wait`, `--dry-run`, and `--format json`. + +## Compatibility policy -Also note that a Review still needs to be requested manually on the Poki for Developers platform (for now). +Cross-release backwards compatibility is guaranteed only for `init`, the `auth login`, `auth status`, and `auth logout` commands, and the deprecated top-level `upload` command. That guarantee covers their documented command names, accepted inputs, core behavior, and documented output, while allowing explicitly documented safety or correctness fixes such as the legacy upload exit-code change above. -## Full usage +No other command, option, normalized response shape, or workflow has a future cross-release backwards-compatibility guarantee or deprecation period. Automation using the modern LLM-focused surface should pin an exact `@poki/cli@VERSION`, review `poki help --all` after an intentional upgrade, and update its assumptions before adopting the new version. + +## Use with an LLM + +After setup, the LLM should start by running: ```sh -$ npx @poki/cli --help +npx @poki/cli +``` + +The resulting structured help explains how to discover and use every supported command. This README intentionally does not duplicate that LLM-facing documentation. + +### Example prompts + +Copy one of these tasks into an LLM agent while it is running in a configured game project. + +#### Analyze game health + +```text +Use the Poki CLI to analyze the configured game's events, errors, and player feedback from the last 30 days. Keep it read-only and report the most important findings with supporting numbers. +``` + +#### Run a playtest -Commands: - poki init Create a poki.json configuration file - poki upload Upload a new version to Poki for Developers +```text +Use the Poki CLI to request 10 playtest recordings for the newest eligible version. Analyze every recording in parallel, summarize the main issues, and do not create a duplicate request. +``` + +#### Compare version activations + +```text +Use the Poki CLI to compare gameplay and revenue before and after recent version activations. Keep it read-only and clearly explain any limits in the data. +``` + +### Daily update guidance -Options: - --version Show version number - -h, --help Show help +Before the first eligible Poki API request in a rolling 24-hour period, the CLI asks npm for the stable `latest` version. The completed command continues normally. If a newer stable version exists, its successful result stays on stdout and a separate structured `CLI_UPDATE_AVAILABLE` notice is written to stderr after completion. -Examples: - poki init --game c7bfd2ba-e23b-486f-9504-a6f196cb44df --build-dir dist - poki upload --name "New Version Name" - poki upload --name "$(git rev-parse --short HEAD)" --notes "$(git log -1 --pretty=%B)" +The notice gives the LLM two exact, version-pinned choices: + +```sh +npm install --global --ignore-scripts --no-audit --no-fund @poki/cli@AVAILABLE_VERSION +npm install --save-dev --ignore-scripts --no-audit --no-fund @poki/cli@AVAILABLE_VERSION ``` + +The LLM should choose the command matching the installation mode, verify it with `poki --version` or `npx @poki/cli --version`, and must not replay the command that already completed. The advisory never self-updates the CLI, never blocks the completed command, and follows only npm's stable `latest` tag. Help, version, auth, offline commands, ordinary dry-runs, analytics validation, and the deprecated legacy upload path do not perform the update lookup. Set `POKI_CLI_UPDATE_CHECK=0` to opt out. Run `poki help updates` for the complete machine-readable contract. + +## License + +The CLI itself is [ISC licensed](LICENSE). The published `bin/index.js` is a bundle that also contains MIT-licensed third-party code; the packages it covers and their required copyright and permission notices are listed at the top of that file. diff --git a/package.json b/package.json index 9a8d32f..0892ee6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@poki/cli", - "version": "0.1.19", + "version": "0.2.0", "description": "Poki for Developers command line utility", "keywords": [ "cli", @@ -14,6 +14,9 @@ "type": "git", "url": "git+https://github.com/poki/poki-cli.git" }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, "license": "ISC", "files": [ "bin/index.js" @@ -23,30 +26,43 @@ }, "scripts": { "cli": "node bin/index.js", - "lint": "ts-standard src rollup.config.mjs --fix", + "lint": "ts-standard src test scripts rollup.config.mjs --fix", + "lint:check": "ts-standard src test scripts rollup.config.mjs", + "test": "node scripts/test.mjs", + "test:package": "node scripts/verify-package.mjs", + "audit:dependencies": "yarn audit", + "typecheck": "tsc --noEmit", + "validate:source": "yarn lint:check && yarn typecheck && yarn test", + "validate": "yarn validate:source && yarn test:package && yarn build", + "release:validate": "yarn validate:source && yarn build && yarn audit:dependencies", + "release:check": "yarn release:validate && yarn test:package --require-clean", + "release:publish": "yarn release:validate && yarn test:package --require-clean --publish", "build": "rollup -c rollup.config.mjs", - "prepublish": "yarn lint && yarn build" + "prepack": "yarn build", + "prepublishOnly": "yarn release:check" }, "dependencies": { - "archiver": "^7.0.1", - "form-data": "^4.0.5", + "archiver": "^8.0.0", + "form-data": "^4.0.6", "open": "^11.0.0", - "yargs": "^18.0.0" + "yargs": "^17.7.2" }, "devDependencies": { - "@babel/core": "^7.29.0", - "@rollup/plugin-babel": "^6.1.0", + "@toon-format/toon": "^4.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/archiver": "^7.0.0", + "@rollup/plugin-typescript": "^12.3.0", + "@types/archiver": "^8.0.0", + "@types/node": "^24.0.0", "@types/yargs": "^17.0.35", "rollup": "^4.59.0", - "rollup-plugin-typescript2": "^0.36.0", + "semver": "^7.7.4", "ts-standard": "^12.0.2", "tslib": "^2.8.1", + "tsx": "^4.20.0", "typescript": "^5.9.3" }, "engines": { - "node": ">=20" + "node": ">=20.7.0" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index 0ba783f..bc7e525 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,28 +1,91 @@ -import { babel } from '@rollup/plugin-babel' +import { readdirSync, readFileSync } from 'node:fs' +import { join, sep } from 'node:path' +import json from '@rollup/plugin-json' import resolve from '@rollup/plugin-node-resolve' -import typescript from 'rollup-plugin-typescript2' +import typescript from '@rollup/plugin-typescript' + +const shebang = '#! /usr/bin/env node' +const licenceFile = /^(licence|license|copying)(\.\w+)?$/i +const nodeModules = `${sep}node_modules${sep}` + +// Returns the package directory a bundled module was resolved from, or +// undefined for first-party source and plugin-generated modules. +function packageRoot (moduleId) { + const start = moduleId.lastIndexOf(nodeModules) + if (start === -1) return undefined + const segments = moduleId.slice(start + nodeModules.length).split(sep) + const depth = segments[0].startsWith('@') ? 2 : 1 + return moduleId.slice(0, start + nodeModules.length) + segments.slice(0, depth).join(sep) +} + +function readLicence (root) { + const name = readdirSync(root).find(entry => licenceFile.test(entry)) + return name === undefined ? undefined : readFileSync(join(root, name), 'utf8').trim() +} + +// Every package that is not listed in `external` below is inlined into +// bin/index.js, and package.json#files publishes that single file, so a bundled +// package's licence notice has nowhere else to live. The notices are derived +// from the modules rollup actually included rather than hard-coded, so a +// package that stops being external ships its notice without anyone +// remembering, and one whose licence cannot be read fails the build instead of +// shipping unattributed. scripts/verify-package.mjs asserts the result survives +// into an install. +function banner (chunk) { + const roots = [...new Set(chunk.moduleIds.map(packageRoot).filter(root => root !== undefined))].sort() + if (roots.length === 0) return `${shebang}\n` + + const packages = roots.map(root => { + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + const licence = readLicence(root) + if (licence === undefined) { + throw new Error(`${manifest.name} is bundled into bin/index.js but ships no licence file; add its notice or make it external.`) + } + return { name: manifest.name, version: manifest.version, licence: manifest.license ?? 'see notice', text: licence } + }) + + const lines = [ + 'This file bundles the third-party packages listed below. Each of their', + 'licences requires its notice to be included with the bundled code, and', + 'this file is the only one the npm package publishes.', + '', + `Bundled packages: ${packages.map(bundled => bundled.name).join(', ')}`, + ...packages.flatMap(bundled => [ + '', + `---- ${bundled.name} ${bundled.version} (${bundled.licence}) ----`, + '', + ...bundled.text.split('\n') + ]) + ] + if (lines.some(line => line.includes('*/'))) { + throw new Error('a bundled licence notice would terminate the banner comment early.') + } + + // The shebang has to stay the very first bytes of the file for the published + // bin to stay executable, so the notice follows it. + return `${shebang}\n/*\n${lines.map(line => ` *${line === '' ? '' : ` ${line}`}`).join('\n')}\n */\n` +} export default { input: './src/index.ts', output: [{ file: './bin/index.js', format: 'cjs', - banner: '#! /usr/bin/env node\n', + banner, exports: 'none' }], plugins: [ resolve(), - typescript(), - babel({ babelHelpers: 'bundled' }) + json(), + typescript() ], + // Node builtins are externalized by the resolve plugin. These are the runtime + // packages the published bundle still requires, which is exactly what + // scripts/verify-package.mjs cross-checks against dependencies. external: [ - 'fs', 'archiver', - 'https', 'form-data', - 'http', - 'os', - 'path', + 'open', 'yargs' ] } diff --git a/scripts/release-git-tag.mjs b/scripts/release-git-tag.mjs new file mode 100644 index 0000000..2cfe376 --- /dev/null +++ b/scripts/release-git-tag.mjs @@ -0,0 +1,101 @@ +import { spawnSync } from 'node:child_process' + +const RELEASE_REMOTE = 'origin' + +function runGit (projectDirectory, arguments_, acceptedStatuses = [0]) { + const result = spawnSync('git', arguments_, { + cwd: projectDirectory, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }) + if (result.error !== undefined) { + throw new Error('git could not be started.', { cause: result.error }) + } + if (result.status === null || !acceptedStatuses.includes(result.status)) { + throw new Error([ + `git ${arguments_.join(' ')} failed${result.status === null ? '' : ` with exit code ${String(result.status)}`}.`, + result.stdout?.trim(), + result.stderr?.trim() + ].filter(Boolean).join('\n')) + } + return result +} + +function localTagTarget (projectDirectory, reference) { + const result = runGit(projectDirectory, ['rev-parse', '--verify', '--quiet', reference], [0, 1]) + return result.status === 0 ? result.stdout.trim() : undefined +} + +function remoteTagTarget (projectDirectory, remote, reference) { + const result = runGit(projectDirectory, ['ls-remote', '--exit-code', '--refs', remote, reference], [0, 2]) + if (result.status === 2) return undefined + const [target, returnedReference, extra] = result.stdout.trim().split(/\s+/) + if (target === undefined || returnedReference !== reference || extra !== undefined) { + throw new Error(`git returned an invalid response while inspecting '${reference}' on '${remote}'.`) + } + return target +} + +export function prepareReleaseGitTag (projectDirectory, version) { + const tag = `v${version}` + const reference = `refs/tags/${tag}` + runGit(projectDirectory, ['check-ref-format', reference]) + + const staged = runGit(projectDirectory, ['diff', '--cached', '--name-only', '--no-ext-diff', 'HEAD', '--']).stdout.trim() + if (staged !== '') { + throw new Error('Release publication requires every indexed change to be committed so the Git tag identifies the exact npm package bytes.') + } + + const commit = runGit(projectDirectory, ['rev-parse', '--verify', 'HEAD^{commit}']).stdout.trim() + if (localTagTarget(projectDirectory, reference) !== undefined) { + throw new Error(`Git tag '${tag}' already exists locally; refusing to publish an npm version whose Git tag is ambiguous.`) + } + if (remoteTagTarget(projectDirectory, RELEASE_REMOTE, reference) !== undefined) { + throw new Error(`Git tag '${tag}' already exists on '${RELEASE_REMOTE}'; refusing to publish an npm version whose Git tag is ambiguous.`) + } + + // Exercise remote selection, authentication, and the ref update before npm's + // irreversible publication. The real push still happens only after npm wins. + runGit(projectDirectory, ['push', '--dry-run', '--porcelain', RELEASE_REMOTE, `${commit}:${reference}`]) + + return Object.freeze({ projectDirectory, remote: RELEASE_REMOTE, tag, reference, commit }) +} + +function incompleteTagError (plan, cause) { + let remoteTarget + let localTarget + let remoteInspected = false + let localInspected = false + try { + remoteTarget = remoteTagTarget(plan.projectDirectory, plan.remote, plan.reference) + remoteInspected = true + } catch {} + try { + localTarget = localTagTarget(plan.projectDirectory, plan.reference) + localInspected = true + } catch {} + + if (remoteInspected && remoteTarget === plan.commit) return undefined + + const recovery = remoteInspected && remoteTarget === undefined && localInspected && localTarget === plan.commit + ? `Run: git push ${plan.remote} ${plan.reference}:${plan.reference}` + : remoteInspected && remoteTarget === undefined && localInspected && localTarget === undefined + ? `After confirming HEAD is still ${plan.commit}, run: git tag ${plan.tag} ${plan.commit} && git push ${plan.remote} ${plan.reference}:${plan.reference}` + : `First inspect '${plan.reference}' locally and on '${plan.remote}'; it must point to ${plan.commit}. Push or create it only after confirming the remote tag is absent, and do not force an existing tag.` + + return new Error([ + `npm publication succeeded, but Git tag '${plan.tag}' was not confirmed on '${plan.remote}'.`, + 'Do not rerun yarn release:publish; npm package versions are immutable.', + recovery + ].join('\n'), { cause }) +} + +export function publishReleaseGitTag (plan) { + try { + runGit(plan.projectDirectory, ['update-ref', plan.reference, plan.commit, '']) + runGit(plan.projectDirectory, ['push', '--porcelain', plan.remote, `${plan.reference}:${plan.reference}`]) + } catch (cause) { + const error = incompleteTagError(plan, cause) + if (error !== undefined) throw error + } +} diff --git a/scripts/release-tag.mjs b/scripts/release-tag.mjs new file mode 100644 index 0000000..2f3363d --- /dev/null +++ b/scripts/release-tag.mjs @@ -0,0 +1,94 @@ +import semver from 'semver' + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ +const SAFE_DIST_TAG = /^[a-z][a-z0-9._-]*$/ + +function prereleaseVersion (version) { + const match = SEMVER.exec(version) + if (match === null) throw new Error(`Package version '${version}' is not valid semantic version syntax.`) + const identifiers = match[4]?.split('.') ?? [] + if (identifiers.some(identifier => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) { + throw new Error(`Package version '${version}' is not valid semantic version syntax.`) + } + return identifiers.length > 0 +} + +function explicitPublishTags (arguments_) { + const tags = [] + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (argument === '-t' || argument.startsWith('-t=')) { + throw new Error('Use the explicit --tag option for release publication.') + } + if (argument === '--tag') { + const value = arguments_[index + 1] + if (value === undefined || value.startsWith('-')) throw new Error('--tag requires one dist-tag value.') + tags.push(value) + index += 1 + continue + } + if (argument.startsWith('--tag=')) tags.push(argument.slice('--tag='.length)) + } + return tags +} + +function explicitDryRun (arguments_) { + const values = [] + for (const argument of arguments_) { + if (argument === '--dry-run') values.push(true) + if (argument === '--no-dry-run') values.push(false) + if (argument.startsWith('--dry-run=')) { + const value = argument.slice('--dry-run='.length) + if (value !== 'true' && value !== 'false') { + throw new Error('--dry-run accepts only true or false.') + } + values.push(value === 'true') + } + } + if (values.length > 1) throw new Error('Release publication accepts exactly one --dry-run option.') + return { explicit: values.length === 1, enabled: values[0] ?? false } +} + +export function validatePublishTag (version, publishArguments) { + const prerelease = prereleaseVersion(version) + const tags = explicitPublishTags(publishArguments) + if (tags.length > 1) throw new Error('Release publication accepts exactly one --tag option.') + + const tag = tags[0] + if (tag !== undefined && !SAFE_DIST_TAG.test(tag)) { + throw new Error(`Invalid npm dist-tag '${tag}'; use a lowercase tag beginning with a letter and containing only letters, digits, dot, underscore, or hyphen.`) + } + if (tag !== undefined && semver.validRange(tag) !== null) { + throw new Error(`Invalid npm dist-tag '${tag}'; npm dist-tags cannot be valid semantic-version ranges. Use a channel name such as 'experimental'.`) + } + if (prerelease && tag === undefined) { + throw new Error('A prerelease package version requires one explicit non-latest --tag.') + } + if (prerelease && tag === 'latest') { + throw new Error('A prerelease package version cannot be published with the latest dist-tag.') + } + + return tag +} + +export function resolveReleasePublishArguments (version, publishArguments) { + const tag = validatePublishTag(version, publishArguments) + const dryRun = explicitDryRun(publishArguments) + + const resolved = [...publishArguments] + // A Git tag is published only after a real npm publication. Own npm's + // dry-run value so ambient npm configuration cannot make those two effects + // disagree. + if (!dryRun.explicit) resolved.push('--dry-run=false') + + // npm otherwise inherits `tag` from user, project, or environment config. + // Own the stable default on the command line so those ambient settings cannot + // silently publish a stable release under a non-latest dist-tag. + return tag === undefined + ? [...resolved, '--tag', 'latest'] + : resolved +} + +export function releasePublishIsDryRun (publishArguments) { + return explicitDryRun(publishArguments).enabled +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..d268e84 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from 'node:child_process' +import { readdirSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const testFiles = readdirSync(join(repository, 'test')) + .filter(name => name.endsWith('.test.ts')) + .sort() + .map(name => join('test', name)) + +if (testFiles.length === 0) throw new Error('No test files found') + +const result = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...testFiles], { + cwd: repository, + stdio: 'inherit' +}) + +if (result.error !== undefined) throw result.error +if (result.signal !== null) throw new Error(`Test process terminated by ${result.signal}`) +process.exitCode = result.status ?? 1 diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..c6fe531 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict' +import { constants } from 'node:fs' +import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { dirname, join, sep } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { isBuiltin } from 'node:module' + +import { prepareReleaseGitTag, publishReleaseGitTag } from './release-git-tag.mjs' +import { releasePublishIsDryRun, resolveReleasePublishArguments } from './release-tag.mjs' + +const projectDirectory = fileURLToPath(new URL('..', import.meta.url)) +// Windows command scripts require a shell and newer Node releases reject +// spawning npm.cmd directly. Invoke npm's JavaScript entry point with the +// active Node executable instead, preserving every argument without a shell. +const npmCommand = process.platform === 'win32' ? process.execPath : 'npm' +const npmArgumentPrefix = process.platform === 'win32' + ? [join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')] + : [] +const arguments_ = process.argv.slice(2) +const shouldPublish = arguments_.includes('--publish') +const publishArguments = arguments_.filter(argument => argument !== '--publish' && argument !== '--require-clean' && argument !== '--') +let resolvedPublishArguments = publishArguments +let projectManifest +let gitTagPlan + +assert.equal( + shouldPublish || publishArguments.length === 0, + true, + 'npm publish options are accepted only together with --publish' +) +assert.equal( + publishArguments.some(argument => argument === '--ignore-scripts' || argument === '--no-ignore-scripts' || argument.startsWith('--ignore-scripts=')), + false, + 'release publication owns npm lifecycle execution; do not pass an ignore-scripts option' +) +if (shouldPublish) { + projectManifest = JSON.parse(await readFile(join(projectDirectory, 'package.json'), 'utf8')) + resolvedPublishArguments = resolveReleasePublishArguments(projectManifest.version, publishArguments) +} + +function run (command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }) + if (result.error !== undefined) { + throw new Error(`${command} could not be started.`, { cause: result.error }) + } + if (result.status !== 0) { + throw new Error([ + `${command} ${args.join(' ')} failed with exit code ${String(result.status)}.`, + result.stdout?.trim(), + result.stderr?.trim() + ].filter(Boolean).join('\n')) + } + return result.stdout ?? '' +} + +function runNpm (args, cwd) { + return run(npmCommand, [...npmArgumentPrefix, ...args], cwd) +} + +function parsePackResult (output) { + const starts = [...output.matchAll(/\[\s*\{\s*"id"\s*:/g)] + const start = starts.at(-1)?.index + assert.notEqual(start, undefined, 'npm pack did not return a JSON result') + return JSON.parse(output.slice(start)) +} + +function runPublish (archive, args) { + // The archive has already run prepack in the clean index checkout and has + // passed every package-install check below. Publish those exact bytes and do + // not give npm a chance to rebuild a different archive from the worktree. + const result = spawnSync(npmCommand, [...npmArgumentPrefix, 'publish', archive, ...args, '--ignore-scripts'], { + cwd: projectDirectory, + stdio: 'inherit' + }) + if (result.error !== undefined) { + throw new Error('npm publish could not be started.', { cause: result.error }) + } + if (result.status !== 0) { + throw new Error(result.signal === null + ? `npm publish failed with exit code ${String(result.status)}.` + : `npm publish was terminated by ${String(result.signal)}.`) + } +} + +if (process.argv.includes('--require-clean')) { + const unstaged = run('git', ['diff', '--name-only', '--no-ext-diff'], projectDirectory).trim() + const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], projectDirectory).trim() + assert.equal(unstaged, '', 'release package verification requires every tracked working-tree change to be in the active Git index') + assert.equal(untracked, '', 'release package verification requires every non-ignored file to be in the active Git index') +} + +if (shouldPublish && !releasePublishIsDryRun(resolvedPublishArguments)) { + gitTagPlan = prepareReleaseGitTag(projectDirectory, projectManifest.version) +} + +// Keep the index checkout below the project so build tools resolve the real +// parent node_modules directory. A Windows junction here makes TypeScript's +// filesystem watcher observe canonical paths outside the watched junction and +// abort Node 24 inside libuv before Rollup can finish the prepack build. +const temporaryDirectory = await mkdtemp(join(projectDirectory, '.poki-cli-package-')) + +try { + // Build exactly what is in the index. Reading paths from the index and bytes + // from the worktree would let unstaged edits (or untracked files) make a + // package check pass even though they are absent from the reviewed index. + run('git', [ + 'checkout-index', + '--all', + '--force', + '--ignore-skip-worktree-bits', + `--prefix=${temporaryDirectory}${sep}` + ], projectDirectory) + + const packDirectory = join(temporaryDirectory, 'packed') + await mkdir(packDirectory) + const packResult = parsePackResult(runNpm( + ['pack', '--ignore-scripts=false', '--json', '--pack-destination', packDirectory], + temporaryDirectory + )) + assert.equal(Array.isArray(packResult), true, 'npm pack did not return a JSON result array') + assert.equal(packResult.length, 1, 'npm pack returned an unexpected number of package results') + assert.equal( + packResult[0].files.some(file => file.path === 'bin/index.js'), + true, + 'the npm package does not contain bin/index.js' + ) + + const installDirectory = join(temporaryDirectory, 'installed') + await mkdir(installDirectory) + runNpm([ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--prefix', installDirectory, + join(packDirectory, packResult[0].filename) + ], temporaryDirectory) + + const installedPackageDirectory = join(installDirectory, 'node_modules', '@poki', 'cli') + const packageJson = JSON.parse(await readFile(join(installedPackageDirectory, 'package.json'), 'utf8')) + const installedCli = join(installedPackageDirectory, 'bin', 'index.js') + const version = run(process.execPath, [installedCli, '--version'], installDirectory).trim() + assert.equal(version, packageJson.version, 'the packaged CLI did not print the package version') + + // Invoke the package-manager-created entry point too. This verifies the bin + // declaration and, on POSIX, the packaged executable mode and shebang. + const installedBinDirectory = join(installDirectory, 'node_modules', '.bin') + const shimVersion = process.platform === 'win32' + ? run(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'poki.cmd --version'], installedBinDirectory).trim() + : run(join(installedBinDirectory, 'poki'), ['--version'], installDirectory).trim() + assert.equal(shimVersion, packageJson.version, 'the installed poki executable did not print the package version') + + run(process.execPath, [ + '--input-type=module', + '--eval', + 'const { ZipArchive } = await import("archiver"); if (typeof ZipArchive !== "function") process.exit(1)' + ], installDirectory) + + const help = JSON.parse(run(process.execPath, [installedCli, 'help', '--format', 'json'], installDirectory)) + assert.equal(help.command, 'poki', 'the installed CLI did not return root structured help') + assert.equal(Array.isArray(help.commands), true, 'the installed CLI help did not contain commands') + assert.notEqual(help.commands.length, 0, 'the installed CLI help command list was empty') + + // The default encoding proves the bundled TOON encoder runs from an install + // that never downloads it; only bundled-at-build packages may leave the + // runtime dependency list. + const toonHelp = run(process.execPath, [installedCli, 'help'], installDirectory) + assert.equal(toonHelp.includes('command: poki'), true, 'the installed CLI did not return TOON root help') + + // Every package the shipped bundle still loads has to be a declared runtime + // dependency, and every declared runtime dependency has to be one the bundle + // actually loads. Rollup's external list is the only thing that decides + // which is which, so drift either way breaks an install nobody tested. + const bundle = await readFile(installedCli, 'utf8') + const loaded = new Set([...bundle.matchAll(/(?:require|import)\(\s*(['"])([^'"]+)\1\s*\)/g)] + .map(match => match[2]) + .filter(specifier => !specifier.startsWith('node:') && !isBuiltin(specifier)) + .map(specifier => specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0])) + const declared = Object.keys(packageJson.dependencies ?? {}) + assert.deepEqual( + [...loaded].filter(name => !declared.includes(name)), + [], + 'the shipped bundle loads a package that is not a runtime dependency' + ) + assert.deepEqual( + declared.filter(name => !loaded.has(name)), + [], + 'a runtime dependency is bundled or unused; move it to devDependencies' + ) + + assert.equal(loaded.has('open'), true, 'the shipped bundle does not load the external open package') + await access( + join(installDirectory, 'node_modules', 'open', 'xdg-open'), + process.platform === 'win32' ? constants.F_OK : constants.X_OK + ) + + // A package rollup inlines is redistributed by us, and its licence requires + // the notice to travel with that copy. The tarball is LICENSE, README.md, + // package.json and bin/index.js, so the bundle banner is the only place the + // notice can ride along. rollup.config.mjs generates it from the modules it + // bundled; this asserts the generated notice reaches an install intact, and + // compares it against the upstream licence file instead of a copy of that + // text, so it fails if the banner is dropped, truncated, or left stale. + const bannerText = bundle.split('\n').map(line => line.replace(/^ \* ?/, '')).join('\n') + assert.equal(bundle.startsWith('#! /usr/bin/env node\n'), true, 'the packaged bundle does not start with the node shebang') + const bundledPackages = bannerText.match(/^Bundled packages: (.+)$/m)?.[1].split(', ') ?? [] + assert.notEqual(bundledPackages.length, 0, 'the packaged bundle carries no third-party licence notice') + for (const name of bundledPackages) { + const packageDirectory = join(projectDirectory, 'node_modules', name) + const licenceFile = (await readdir(packageDirectory)).find(entry => /^(licence|license|copying)(\.\w+)?$/i.test(entry)) + assert.notEqual(licenceFile, undefined, `the bundled package ${name} ships no licence file to check the notice against`) + const licence = await readFile(join(packageDirectory, licenceFile), 'utf8') + for (const paragraph of licence.trim().split('\n\n')) { + assert.equal(bannerText.includes(paragraph), true, `the packaged bundle inlines ${name} without its full licence notice`) + } + } + + if (shouldPublish) { + runPublish(join(packDirectory, packResult[0].filename), resolvedPublishArguments) + if (gitTagPlan !== undefined) publishReleaseGitTag(gitTagPlan) + } +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }) +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..9f2d6fa --- /dev/null +++ b/src/api.ts @@ -0,0 +1,339 @@ +import { decodeBearerCredentials, readStoredAuth, refreshStoredAuth } from './auth' +import { Config } from './config' +import { authRequired, CliError, safeApiErrorResponse } from './errors' +import { serviceEnvironment } from './service-environment' +import { DEFAULT_DOWNLOAD_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_UPLOAD_TIMEOUT_MS, timeoutMillisecondsOrDefault } from './timeouts' +import { CLI_USER_AGENT } from './version' + +export type ResponseType = 'json' | 'text' + +export interface ApiRequest { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' + path: string + query?: URLSearchParams + body?: unknown + contentType?: string + accept?: string + responseType?: ResponseType + rawBody?: BodyInit + timeoutMs?: number + // Marks a non-GET request whose failure is safe to retry (the read-only + // analytics POST /_data); timeout and network errors key retryable off this + // in addition to the HTTP method. + retrySafe?: boolean +} + +export interface ApiResponse { + status: number + headers: Headers + body: T +} + +export interface ApiClientDependencies { + fetch: typeof globalThis.fetch + readAuth: () => Config | undefined + refreshAuth: (config: Config) => Promise + beforeFirstRequest: () => Promise +} + +export function apiResponseError (status: number, body: unknown, headers: Headers, request: ApiRequest): CliError { + const safeDetails = safeApiErrorResponse(body) + const firstError = safeDetails.errors?.[0] + const firstErrorCode = firstError?.code + const firstErrorDetail = firstError?.detail + const firstErrorTitle = firstError?.title + const message = firstErrorDetail ?? firstErrorTitle ?? `Poki API request failed with status ${status}.` + // Only the reviewed JSON:API errors array contributes a public code or + // message. Legacy top-level error/message fields are arbitrary backend + // payload and therefore collapse to the stable HTTP status contract. + const sourceCode = firstErrorCode ?? `HTTP_${status}` + const normalizedCode = sourceCode.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase() + const explicitPermissionDenied = status === 403 && firstErrorCode === 'permission-denied' + const code = explicitPermissionDenied + ? 'PERMISSION_DENIED' + : normalizedCode === '' || normalizedCode === 'PERMISSION_DENIED' + ? `HTTP_${status}` + : normalizedCode + const method = request.method ?? 'GET' + const retrySafe = method === 'GET' || request.retrySafe === true + const transient = status === 408 || status === 429 || status >= 500 + const redirect = status >= 300 && status < 400 + return new CliError(code, message, status === 401 ? 3 : status >= 500 ? 5 : 4, { + status, + ...(Object.keys(safeDetails).length === 0 ? {} : { details: safeDetails }), + retryable: transient && retrySafe, + requestId: headers.get('x-request-id') ?? headers.get('x-cloud-trace-context') ?? undefined, + retryAfter: headers.get('retry-after') ?? undefined, + ...(status === 401 + ? { hint: 'Run `poki auth login` (opens a browser and needs a human to complete sign-in).' } + : (transient || redirect) && !retrySafe + ? { hint: `The ${method} outcome may be unknown. Read the resource state before deciding whether to retry this mutation.` } + : {}) + }) +} + +export class ApiClient { + readonly baseUrl: string + private readonly transport: typeof globalThis.fetch + private readonly readAuth: () => Config | undefined + private readonly refreshAuth: (config: Config) => Promise + private beforeFirstRequest: (() => Promise) | undefined + private firstRequestPreparation: Promise | undefined + readonly timeoutMs: number + readonly uploadTimeoutMs: number + readonly downloadTimeoutMs: number + + constructor ( + baseUrl = serviceEnvironment().apiUrl, + dependencies: Partial = {} + ) { + this.baseUrl = baseUrl.replace(/\/$/, '') + this.transport = dependencies.fetch ?? globalThis.fetch + this.readAuth = dependencies.readAuth ?? readStoredAuth + this.refreshAuth = dependencies.refreshAuth ?? refreshStoredAuth + this.beforeFirstRequest = dependencies.beforeFirstRequest + this.timeoutMs = timeoutMillisecondsOrDefault(process.env.POKI_API_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS) + // Multipart build and asset uploads legitimately take longer than API + // reads; a 30 s ceiling would abort ordinary multi-megabyte uploads. + this.uploadTimeoutMs = timeoutMillisecondsOrDefault(process.env.POKI_API_TIMEOUT_MS, DEFAULT_UPLOAD_TIMEOUT_MS) + // One deadline covers connect, headers, and the complete streamed archive, + // and a failed transfer discards every byte already written, so signed + // downloads need the same headroom as the uploads they mirror. + this.downloadTimeoutMs = timeoutMillisecondsOrDefault(process.env.POKI_API_TIMEOUT_MS, DEFAULT_DOWNLOAD_TIMEOUT_MS) + } + + addBeforeFirstRequestHook (hook: () => Promise): void { + const previous = this.beforeFirstRequest + this.beforeFirstRequest = previous === undefined + ? hook + : async () => { + await previous() + await hook() + } + } + + async request (request: ApiRequest): Promise> { + const config = decodeBearerCredentials(this.readAuth()) + if (config?.access_token === undefined) { + throw authRequired() + } + + let response = await this.execute(request, config.access_token) + if (response.status === 401) { + if (config.refresh_token !== undefined) { + try { + const refreshed = decodeBearerCredentials(await this.refreshAuth(config)) + if (refreshed?.access_token === undefined) { + throw authRequired('Authentication expired and could not be refreshed.') + } + // A 401 rejection happens before the server executes the request, + // so replaying a mutation once after a refresh cannot double-apply. + response = await this.execute(request, refreshed.access_token) + } catch (error) { + if (error instanceof CliError) throw error + throw authRequired('Authentication expired and could not be refreshed.') + } + } + if (response.status === 401) throw authRequired('Authentication was rejected by the Poki API.') + } + + if (response.status < 200 || response.status >= 300) { + throw apiResponseError(response.status, response.body, response.headers, request) + } + return response + } + + resolveExternalLocation (location: string): string { + return this.externalUrl(location, this.baseUrl).toString() + } + + // The configured API origin is the complete authenticated transport boundary, + // so which origins the CLI will contact is decided in exactly one place. + // Copies of this rule in the request path and in pagination could disagree. + isApiOrigin (url: URL): boolean { + return url.origin === new URL(this.baseUrl).origin + } + + // Resolves the path or absolute link an API request or a followed pagination + // link names, and refuses anything outside the configured origin. + resolveApiUrl (path: string, query?: URLSearchParams): URL { + const url = /^https?:\/\//.test(path) + ? new URL(path) + : new URL(this.baseUrl + (path.startsWith('/') ? path : `/${path}`)) + if (!this.isApiOrigin(url)) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned a pagination link for another origin.', 5, { + details: { expected: 'configured_api_origin', received_kind: 'different_origin' } + }) + } + if (query !== undefined) url.search = query.toString() + return url + } + + async downloadExternal ( + location: string, + consumeBody: (body: ReadableStream | null) => Promise, + timeoutMs = this.downloadTimeoutMs + ): Promise> { + const url = this.externalUrl(location) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await this.transport(url, { + method: 'GET', + headers: { Accept: 'application/octet-stream', 'User-Agent': CLI_USER_AGENT }, + signal: controller.signal + }) + + if (response.status < 200 || response.status >= 300) { + // Do not leave a failed signed response streaming after the public HTTP + // error has been classified or let it outlive the request deadline. + controller.abort() + try { + await response.body?.cancel() + } catch { + // Preserve the public signed-download error below. + } + throw new CliError(`HTTP_${response.status}`, `Signed download failed with status ${response.status}.`, response.status >= 500 ? 5 : 4, { + status: response.status, + retryable: response.status === 408 || response.status === 429 || response.status >= 500, + requestId: response.headers.get('x-request-id') ?? undefined, + retryAfter: response.headers.get('retry-after') ?? undefined + }) + } + + const body = await consumeBody(response.body) + return { + status: response.status, + headers: response.headers, + body + } + } catch (error) { + if (error instanceof CliError) throw error + if (controller.signal.aborted) { + throw new CliError('API_TIMEOUT', `The signed download request exceeded ${timeoutMs} ms.`, 5, { + details: { timeout_ms: timeoutMs }, + retryable: true, + hint: 'Request a new signed URL, then retry the download or increase --timeout-ms.' + }) + } + throw new CliError('NETWORK_ERROR', 'Could not reach the signed download location.', 5, { + retryable: true + }) + } finally { + clearTimeout(timeout) + } + } + + private externalUrl (location: string, base?: string): URL { + let url: URL + try { + url = base === undefined ? new URL(location) : new URL(location, base) + } catch { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an invalid download URL.', 5, { + details: { + expected: base === undefined ? 'absolute_http_or_https_url' : 'http_or_https_url', + received_kind: 'invalid_url' + } + }) + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an unsupported download URL protocol.', 5, { + details: { + expected: base === undefined ? 'absolute_http_or_https_url' : 'http_or_https_url', + received_kind: 'unsupported_protocol' + } + }) + } + return url + } + + private async execute (request: ApiRequest, accessToken: string): Promise> { + const url = this.resolveApiUrl(request.path, request.query) + + const headers: Record = { + Accept: request.accept ?? 'application/vnd.api+json', + Authorization: `Bearer ${accessToken}`, + 'User-Agent': CLI_USER_AGENT + } + let body: BodyInit | undefined = request.rawBody + if (request.body !== undefined && request.rawBody !== undefined) { + throw new CliError('INVALID_INPUT', 'An API request cannot have both body and rawBody.', 2) + } + if (request.body !== undefined) { + headers['Content-Type'] = request.contentType ?? 'application/vnd.api+json' + body = JSON.stringify(request.body) + } + + if (this.beforeFirstRequest !== undefined) { + this.firstRequestPreparation ??= this.beforeFirstRequest() + await this.firstRequestPreparation + } + + const timeoutMs = request.timeoutMs ?? (request.rawBody !== undefined ? this.uploadTimeoutMs : this.timeoutMs) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + const method = request.method ?? 'GET' + const readOnly = request.retrySafe === true || method === 'GET' + try { + const response = await this.transport(url, { + method, + headers, + body, + // The configured API origin is the complete authenticated transport + // boundary. Fetch follows redirects by default, and a 307 or 308 can + // resend a mutation body to the redirect target. Surface every 3xx to + // request() instead so mutations retain inspect-before-replay recovery. + // Signed downloads intentionally keep their separate redirect behavior. + redirect: 'manual', + signal: controller.signal + }) + + const text = await response.text() + let parsed: unknown = text + const wantsJson = request.responseType !== 'text' + if (wantsJson && text !== '') { + try { + parsed = JSON.parse(text) + } catch (error) { + if (response.status >= 200 && response.status < 300) { + const mutation = method !== 'GET' && request.retrySafe !== true + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned invalid JSON.', 5, { + status: response.status, + retryable: false, + requestId: response.headers.get('x-request-id') ?? response.headers.get('x-cloud-trace-context') ?? undefined, + ...(mutation + ? { hint: `The ${method} mutation may already have committed. Inspect current resource state and do not replay the mutation.` } + : {}) + }) + } + } + } else if (text === '' && request.responseType !== 'text') { + parsed = null + } + + return { + status: response.status, + headers: response.headers, + body: parsed as T + } + } catch (error) { + if (error instanceof CliError) throw error + if (controller.signal.aborted) { + throw new CliError('API_TIMEOUT', `The Poki API request exceeded ${timeoutMs} ms.`, 5, { + details: { method, path: url.pathname, timeout_ms: timeoutMs }, + retryable: readOnly, + hint: readOnly + ? 'Retry the read or increase --timeout-ms.' + : 'Check the resource state before retrying this mutation.' + }) + } + throw new CliError('NETWORK_ERROR', 'Could not reach the Poki API.', 5, { + retryable: readOnly, + ...(readOnly ? {} : { hint: `The ${method} outcome may be unknown. Read the resource state before deciding whether to retry this mutation.` }) + }) + } finally { + clearTimeout(timeout) + } + } +} diff --git a/src/audiences.ts b/src/audiences.ts new file mode 100644 index 0000000..9471611 --- /dev/null +++ b/src/audiences.ts @@ -0,0 +1,203 @@ +export interface AudienceDefinition { + id: number + name: string + enabled_for_testing: boolean +} + +// Bundled snapshot of the content categories used by Poki for Developers. +// Keeping this data in the CLI makes category discovery deterministic and +// offline. The backend remains authoritative when a category is submitted. +const audienceEntries: Array<[number, string, boolean?]> = [ + [1, 'Racing Games', true], + [2, 'Sports Games', true], + [3, 'Action Games', true], + [4, 'Games for Girls', true], + [6, 'Adventure Games', true], + [7, 'Brain Games', true], + [8, 'Board Games', true], + [9, 'Skill Games', true], + [11, 'Battleship Games'], + [12, 'Pool Games'], + [13, 'Block Games', true], + [14, 'Boxing Games'], + [16, 'Hidden Object Games', true], + [18, 'Cooking Games', true], + [20, 'Decoration Games'], + [23, 'Chess Games', true], + [25, 'Music Games'], + [27, 'Soccer Games', true], + [29, 'Dress Up Games', true], + [32, 'Boat Games'], + [33, 'Checkers Games'], + [34, 'Drawing Games', true], + [35, 'Math Games', true], + [37, 'Mouse Games', true], + [38, 'Maze Games', true], + [41, 'Mahjong Games'], + [44, 'Make Up Games', true], + [48, 'Platform Games', true], + [49, 'Ragdoll Games', true], + [50, 'Hair Games', true], + [51, 'Motorbike Games'], + [52, 'Shopping Games'], + [53, 'Snake Games', true], + [54, 'Sudoku Games'], + [58, 'Zombie Games', true], + [61, 'RPG Games'], + [64, 'Animal Games', true], + [65, 'War Games', true], + [66, 'Strategy Games', true], + [67, 'Point and Click Games', true], + [68, 'Love Games'], + [69, 'Management Games', true], + [72, 'Puzzle Games', true], + [74, 'Mini Games'], + [76, 'Multiplayer Games', true], + [77, 'Shooting Games', true], + [78, 'Car Games', true], + [80, 'Fighting Games', true], + [81, 'Police Games', true], + [82, 'Bicycle Games'], + [83, 'Geography Games'], + [84, 'Card Games', true], + [85, 'Fashion Games', true], + [86, 'Train Games'], + [87, 'Construction Games'], + [88, 'Christmas Games', true], + [91, 'Simulation Games', true], + [93, '3D Games', true], + [95, 'Word Games'], + [96, 'Funny Games', true], + [99, 'Robot Games', true], + [100, 'Doctor Games'], + [102, 'Bubble Games'], + [103, 'Arcade Games'], + [118, 'Farm Games'], + [129, 'Baseball Games'], + [130, 'Basketball Games', true], + [136, 'BMX Games'], + [139, 'Bowling Games'], + [144, 'Hunting Games'], + [145, 'Cat Games', true], + [157, 'Cricket Games'], + [179, 'Golf Games'], + [183, 'Halloween Games', true], + [188, 'Hospital Games'], + [201, 'Makeover Games', true], + [209, 'Easter Games'], + [211, 'Parking Games'], + [222, 'Jigsaw Puzzle Games'], + [228, 'Escape Games', true], + [239, 'Skateboarding Games'], + [242, 'Sniper Games', true], + [249, 'Tank Games', true], + [250, 'Typing Games', true], + [251, 'Tennis Games'], + [253, 'Archery Games'], + [254, 'Bubble Shooter Games'], + [257, 'Tower Defense Games', true], + [260, 'Bike Games', true], + [272, 'Mini Golf Games'], + [274, 'Fishing Games'], + [277, 'Pizza Games'], + [279, 'Cake Games'], + [281, 'Princess Games'], + [333, 'Ninja Games', true], + [377, 'Truck Games', true], + [379, 'Airplane Games', true], + [380, 'Tractor Games'], + [381, 'Dog Games'], + [383, 'Wrestling Games'], + [384, 'Dragon Games'], + [385, 'Gun Games', true], + [386, 'Bus Games'], + [388, 'Restaurant Games', true], + [389, 'Dinosaur Games', true], + [390, 'Monster Truck Games', true], + [392, 'Solitaire Games', true], + [399, 'GTA Games', true], + [400, 'Logic Games', true], + [404, 'Fish Games'], + [414, 'Monkey Games'], + [734, 'Flappy Bird Games'], + [735, 'World Cup Games'], + [738, 'Dirt Bike Games'], + [744, 'Minecraft Games', true], + [748, 'Surgery Games'], + [750, '2 Player Games', true], + [775, 'Ball Games', true], + [792, 'Ice Cream Games'], + [802, 'Penalty Games'], + [823, 'Educational Games', true], + [826, 'Scary Games', true], + [832, 'Intelligence Games', true], + [839, 'Food Games', true], + [842, 'Monster Games', true], + [843, 'Quiz Games'], + [852, 'Classic Games', true], + [854, 'Football Games'], + [869, 'Unity Games'], + [873, 'Papa\'s Games'], + [885, 'Parkour Games', true], + [893, 'Driving Games', true], + [898, 'Hockey Games'], + [903, 'Running Games', true], + [905, 'Anime Games'], + [909, 'Matching Games', true], + [927, 'Stickman Games', true], + [929, 'Games for Boys', true], + [931, 'Superhero Games'], + [933, 'Number Games'], + [972, 'Magic Games'], + [1013, 'Physics Games', true], + [1014, 'Idle Games', true], + [1018, 'Survival Games', true], + [1082, 'Match 3 Games'], + [1083, 'Army Games'], + [1103, 'HTML5 Games'], + [1120, '.io Games', true], + [1122, 'Crafting Games', true], + [1123, 'Olympics Games'], + [1126, 'Cool Games', true], + [1130, 'Clicker Games', true], + [1131, 'Slime Games'], + [1136, 'Battle Royale Games'], + [1137, 'App Store Games'], + [1139, 'Crazy Games', true], + [1140, 'Popular Games'], + [1141, 'New Games'], + [1143, 'Online Games'], + [1147, 'Video Games'], + [1149, 'Meme Games'], + [1154, 'Flash Games'], + [1155, 'Merge Games', true], + [1161, 'Henry Stickmin Games'], + [1162, 'Horror Games'], + [1164, 'First Person Shooter Games', true], + [1165, 'Color Games', true], + [1166, 'Story Games'], + [1167, 'Horse Games'], + [1168, 'Co-op Games', true], + [1169, 'Space Games'], + [1171, 'Retro Games', true], + [1177, 'Winter Games', true], + [1178, 'Drifting Games', true], + [1180, 'Nitrome Games'], + [1185, 'Obby Games', true], + [1186, 'Watermelon Games', true], + [1187, 'Cozy Games', true], + [1189, 'All Categories'], + [1190, 'Mobile Games'], + [1193, 'Difficult Games'], + [1196, 'Easy Games'], + [1201, '1v1 Games'], + [1205, 'Tycoon Games', true], + [1206, 'Brainrot Games', true], + [1209, 'Roblox Games', true] +] + +export const audienceCatalog: AudienceDefinition[] = audienceEntries.map(([id, name, enabledForTesting]) => ({ + id, + name, + enabled_for_testing: enabledForTesting === true +})) diff --git a/src/auth.ts b/src/auth.ts index c7a5087..7181e3a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,137 +1,332 @@ -import { createServer } from 'http' -import { request } from 'https' -import { writeFileSync, readFileSync, existsSync, mkdirSync, chmodSync } from 'fs' +import { createServer, RequestListener } from 'http' +import { randomUUID } from 'crypto' +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' import { join } from 'path' -import open from 'open' - import { getConfigDir, Config } from './config' +import { authRequired } from './errors' +import { serviceEnvironment } from './service-environment' +import { DEFAULT_REQUEST_TIMEOUT_MS, timeoutMillisecondsOrDefault } from './timeouts' +import { CLI_USER_AGENT } from './version' -async function exchange (exchangeToken: string): Promise { - return await new Promise((resolve, reject) => { - const req = request({ - hostname: 'auth.poki.io', - port: 443, - path: '/auth/exchange', - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }, res => { - let data = '' - res.on('data', (chunk: string) => { - data += chunk - }) - res.on('end', () => { - if (res.statusCode !== 200) { - reject(data) - } else { - resolve(JSON.parse(data)) - } - }) - }) +type Log = (message: string) => void +interface BrowserOpenerModule { default: (target: string) => Promise } +type BrowserOpenerImport = () => Promise - req.on('error', error => { - reject(error) - }) +const maxAuthResponseBytes = 64 * 1024 - req.write(JSON.stringify({ exchange_token: exchangeToken })) - req.end() - }) +class AuthRequestError extends Error {} + +export async function launchBrowser ( + target: string, + importOpener: BrowserOpenerImport = async () => await import('open') +): Promise { + const { default: open } = await importOpener() + await open(target) } -export async function refresh (config: Config): Promise { - console.log('refreshing authentication...') +function isCredentialToken (value: unknown): value is string { + // OAuth credentials are placed in HTTP headers. Restrict them to visible + // ASCII so successful auth responses can never persist a value that Fetch + // later rejects or that changes when serialized to disk. + return typeof value === 'string' && /^[\x21-\x7e]+$/.test(value) +} - return await new Promise((resolve, reject) => { - const req = request({ - hostname: 'auth.poki.io', - port: 443, - path: '/auth/refresh', - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }, res => { - let data = '' - res.on('data', (chunk: string) => { - data += chunk - }) - res.on('end', () => { - if (res.statusCode !== 200) { - reject(data) - } else { - const configPath = join(getConfigDir(), 'auth.json') - const body = JSON.parse(data) +function decodeCredentials ( + value: unknown, + options: { requireAccessToken: boolean, allowUploadToken: boolean } +): Config | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined + const source = value as Record - config.access_token = body.access_token + const accessToken = source.access_token + if (accessToken !== undefined && !isCredentialToken(accessToken)) return undefined + if (options.requireAccessToken && !isCredentialToken(accessToken)) return undefined - writeFileSync(configPath, JSON.stringify(config), 'ascii') + const refreshToken = source.refresh_token + if (refreshToken !== undefined && !isCredentialToken(refreshToken)) return undefined - // Make sure the file isn't readable for everyone. - chmodSync(configPath, '600') + const accessType = source.access_type + if (accessType !== undefined && accessType !== 'Bearer' && !(options.allowUploadToken && accessType === 'Token')) return undefined - resolve(config) - } - }) - }) + return { + ...(typeof accessToken === 'string' ? { access_token: accessToken } : {}), + ...(typeof refreshToken === 'string' ? { refresh_token: refreshToken } : {}), + ...(typeof accessType === 'string' ? { access_type: accessType } : {}) + } +} + +export function decodeBearerCredentials (value: unknown): Config | undefined { + return decodeCredentials(value, { requireAccessToken: true, allowUploadToken: false }) +} - req.on('error', error => { - reject(error) +export interface AuthStatus { + authenticated: boolean + credentials_present: boolean + source: 'stored' | 'none' + access_type?: string + expires_at?: string + expired?: boolean + refreshable: boolean +} + +export function getAuthPath (): string { + return join(getConfigDir(), 'auth.json') +} + +export function readStoredAuth (): Config | undefined { + try { + return decodeCredentials(JSON.parse(readFileSync(getAuthPath(), 'utf8')), { + requireAccessToken: true, + allowUploadToken: true }) + } catch (error) { + return undefined + } +} - req.write(JSON.stringify({ refresh_token: config.refresh_token })) - req.end() - }) +function writeStoredAuth (config: Config): void { + const configDir = getConfigDir() + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } + + // Publish credentials from a same-directory temporary file, the way completed + // downloads are published. Writing auth.json directly opens it with O_TRUNC, + // so an invocation starting while this one refreshes an expired token reads an + // empty file and reports AUTH_REQUIRED for perfectly valid credentials, and an + // interruption between open and write empties it permanently. Rename is atomic + // on the same filesystem, so a concurrent reader always sees either the + // complete old or the complete new document. + const temporary = join(configDir, `.poki-auth-${process.pid}-${randomUUID()}.tmp`) + try { + // Create the file already restricted: a mode applied after the write leaves + // the credentials briefly readable, and permanently so if the process dies + // between the two calls. Repairing the mode here rather than on auth.json + // also means a world-readable file an older CLI created under the default + // umask is replaced by the rename instead of receiving the fresh token + // first. + writeFileSync(temporary, JSON.stringify(config), { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + chmodSync(temporary, '600') + renameSync(temporary, getAuthPath()) + } catch (error) { + try { + unlinkSync(temporary) + } catch { + // The temporary file may never have been created; keep the original + // failure, which describes why the credentials were not persisted. + } + throw error + } } -export async function auth (force = false): Promise { - return await new Promise((resolve, reject) => { - const configDir = getConfigDir() - const configPath = join(configDir, 'auth.json') - let config: Config | undefined +function tokenExpiry (token: string | undefined): number | undefined { + if (token === undefined) return undefined + + try { + const payload = token.split('.')[1] + if (payload === undefined) return undefined + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown } + if (typeof decoded.exp !== 'number' || !Number.isFinite(decoded.exp)) return undefined + const expiry = decoded.exp * 1000 + return Number.isFinite(expiry) && !Number.isNaN(new Date(expiry).getTime()) ? expiry : undefined + } catch (error) { + return undefined + } +} - if (!force) { - try { - config = JSON.parse(readFileSync(configPath, 'ascii')) as Config - } catch (e) { - // Ignore. - } +export function getAuthStatus (): AuthStatus { + const config = readStoredAuth() + if (config?.access_token === undefined) { + return { + authenticated: false, + credentials_present: false, + source: 'none', + refreshable: false } + } + + const expiry = tokenExpiry(config.access_token) + const expired = expiry !== undefined && expiry <= Date.now() + const accessType = config.access_type ?? 'Bearer' + return { + authenticated: !expired && accessType !== 'Token', + credentials_present: true, + source: 'stored', + access_type: accessType, + ...(expiry === undefined + ? {} + : { + expires_at: new Date(expiry).toISOString(), + expired + }), + refreshable: config.refresh_token !== undefined + } +} + +export function logoutStoredAuth (): boolean { + if (!existsSync(getAuthPath())) return false + unlinkSync(getAuthPath()) + return true +} - if (typeof process.env.POKI_ACCESS_TOKEN === 'string') { - console.warn('POKI_ACCESS_TOKEN has been deprecated, please use POKI_UPLOAD_TOKEN') +function authTimeoutMs (): number { + return timeoutMillisecondsOrDefault(process.env.POKI_API_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS) +} + +function authEndpoint (path: string, baseUrl: string): string { + try { + const base = new URL(baseUrl) + if (base.protocol !== 'http:' && base.protocol !== 'https:') throw new Error('unsupported protocol') + return new URL(path, base).toString() + } catch { + throw new AuthRequestError('The authentication service URL is invalid.') + } +} + +async function cancelResponseBody (response: Response): Promise { + try { + await response.body?.cancel() + } catch { + // Preserve the public authentication error that caused the cancellation. + } +} - config = { - ...config, - access_type: 'Token', - access_token: process.env.POKI_ACCESS_TOKEN +async function boundedResponseText (response: Response, operation: 'exchange' | 'refresh'): Promise { + const contentLength = Number(response.headers.get('content-length')) + if (Number.isFinite(contentLength) && contentLength > maxAuthResponseBytes) { + await cancelResponseBody(response) + throw new AuthRequestError(`The authentication ${operation} response was too large.`) + } + + const reader = response.body?.getReader() + if (reader === undefined) return '' + + const chunks: Uint8Array[] = [] + let bytes = 0 + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + bytes += chunk.value.byteLength + if (bytes > maxAuthResponseBytes) { + try { + await reader.cancel() + } catch {} + throw new AuthRequestError(`The authentication ${operation} response was too large.`) } + chunks.push(chunk.value) } + } finally { + try { + reader.releaseLock() + } catch {} + } + return Buffer.concat(chunks.map(chunk => Buffer.from(chunk)), bytes).toString('utf8') +} + +async function exchange (exchangeToken: string, authUrl: string): Promise { + return await postAuth('/auth/exchange', { exchange_token: exchangeToken }, 'exchange', authUrl) +} - if (typeof process.env.POKI_UPLOAD_TOKEN === 'string') { - config = { - ...config, - access_type: 'Token', - access_token: process.env.POKI_UPLOAD_TOKEN +async function postAuth ( + path: string, + body: Record, + operation: 'exchange' | 'refresh', + authUrl: string +): Promise { + const timeoutMs = authTimeoutMs() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + let response: Response + try { + response = await fetch(authEndpoint(path, authUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': CLI_USER_AGENT }, + body: JSON.stringify(body), + redirect: 'manual', + signal: controller.signal + }) + } catch (error) { + if (error instanceof AuthRequestError) throw error + if (controller.signal.aborted) { + throw new AuthRequestError(`The authentication ${operation} request exceeded ${String(timeoutMs)} ms.`) } + throw new AuthRequestError(`Could not reach the authentication service for ${operation}.`) + } + + if (response.status !== 200) { + await cancelResponseBody(response) + throw new AuthRequestError(`Authentication ${operation} failed with status ${String(response.status)}.`) } - if (config !== undefined) { - resolve(config) - return + let data: string + try { + data = await boundedResponseText(response, operation) + } catch (error) { + if (error instanceof AuthRequestError) throw error + if (controller.signal.aborted) { + throw new AuthRequestError(`The authentication ${operation} request exceeded ${String(timeoutMs)} ms.`) + } + throw new AuthRequestError(`Could not read the authentication ${operation} response.`) + } + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + throw new AuthRequestError(`The authentication ${operation} returned invalid JSON.`) } + const credentials = decodeBearerCredentials(parsed) + if (credentials === undefined) { + throw new AuthRequestError(`The authentication ${operation} returned invalid credentials.`) + } + + // Keep the stored credential shape independent of future auth-service + // response members. In particular, a refresh response must not overwrite + // project settings or switch API credentials into legacy upload-token + // mode merely because it contains an unexpected field. + return credentials + } finally { + clearTimeout(timeout) + } +} + +export async function refreshStoredAuth (config: Config, authUrl = serviceEnvironment().authUrl): Promise { + const current = decodeCredentials(config, { requireAccessToken: false, allowUploadToken: false }) + if (current?.refresh_token === undefined) { + throw new Error('No refresh token found') + } + + const body = await postAuth('/auth/refresh', { refresh_token: current.refresh_token }, 'refresh', authUrl) + const refreshed = decodeBearerCredentials({ ...current, ...body }) + if (refreshed === undefined) throw new AuthRequestError('The authentication refresh returned invalid credentials.') + writeStoredAuth(refreshed) + return refreshed +} + +export async function refresh (config: Config, log: Log = console.log): Promise { + log('refreshing authentication...') + return await refreshStoredAuth(config) +} + +async function interactiveLogin (log: Log): Promise { + log('authentication required, opening browser...') - console.log('authentication required, opening browser...') + const environment = serviceEnvironment() + return await new Promise((resolve, reject) => { + const configDir = getConfigDir() if (!existsSync(configDir)) { try { mkdirSync(configDir, { recursive: true }) - } catch (err) { - reject(err) + } catch (error) { + reject(error) return } } - const server = createServer((req, res) => { + const callback: RequestListener = (req, res) => { if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') @@ -141,55 +336,121 @@ export async function auth (force = false): Promise { return } - const q = new URL(req.url as string, 'http://localhost') - - if (q.pathname === '/favicon.ico') { + const url = new URL(req.url as string, 'http://localhost') + if (url.pathname === '/favicon.ico') { res.writeHead(404) res.end() return } - const exchangeToken = q.searchParams.get('exchange_token') - if (exchangeToken !== null) { - res.setHeader('Content-Type', 'text/html') - res.writeHead(200) - res.end('You can close this window and return to your terminal') - - server.close() - - exchange(exchangeToken).then((config: Config) => { - writeFileSync(configPath, JSON.stringify(config), 'ascii') - - // Make sure the file isn't readable for everyone. - chmodSync(configPath, '600') - - resolve(config) - }).catch(err => { - reject(err) - }) - } else { + const exchangeToken = url.searchParams.get('exchange_token') + if (exchangeToken === null) { res.setHeader('Content-Type', 'text/plain') + res.setHeader('Connection', 'close') res.writeHead(200) - res.end('missing exchange_token') + res.end('missing exchange_token', () => shutdown()) + reject(new Error('missing exchange_token')) + return + } + + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.writeHead(200) + res.end('You can close this window and return to your terminal', () => shutdown()) - server.close() + exchange(exchangeToken, environment.authUrl).then(config => { + writeStoredAuth(config) + resolve(config) + }).catch(reject) + } - reject(Error('missing exchange_token')) + // Only the local browser completes this flow, so the callback listens on + // loopback rather than every network interface. The callback URL has to + // keep the `localhost` hostname because the authentication service + // allowlists it, and that name resolves to either loopback family, so IPv6 + // gets a best-effort second listener on the same port. + const server = createServer(callback) + const ipv6Server = createServer(callback) + + // close() only stops the listener. The browser's keep-alive socket - and any + // speculative preconnect it opened - stay referenced and would hold the + // event loop open for the full idle timeout after login already succeeded. + const shutdown = (): void => { + for (const listener of [server, ipv6Server]) { + listener.close() + listener.closeAllConnections() } - }) - server.on('error', (err) => { - reject(err) - }) - server.listen(0, () => { - const address = server.address() + } + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() if (address === null || typeof address === 'string') { + shutdown() + reject(new Error('Could not determine the local authentication callback address')) return } - open(`https://app.poki.dev/signin/?cli=${encodeURIComponent(`http://localhost:${address.port}`)}`).catch(err => { - reject(err) - }) + let browserOpened = false + const openBrowser = (): void => { + if (browserOpened) return + browserOpened = true + launchBrowser(`${environment.signInUrl}?cli=${encodeURIComponent(`http://localhost:${address.port}`)}`).catch(error => { + shutdown() + reject(error) + }) + } + + // A host without IPv6 loopback keeps working on the IPv4 listener alone. + // The error listener stays attached so a later failure on the optional + // listener cannot surface as an unhandled event. + ipv6Server.on('error', openBrowser) + ipv6Server.listen(address.port, '::1', openBrowser) }) }) } + +export async function login (log: Log = console.error): Promise { + // Without a terminal nobody can complete the browser flow; the structured + // auth error beats opening a browser and blocking on the callback forever. + // stdout is deliberately not part of that check: it carries the structured + // result, so `poki auth login --format json > file` is an ordinary human + // invocation. stdin and stderr are the streams that stay attached to the + // session, and a process with neither still refuses. + if (!(process.stdin.isTTY ?? false) && !(process.stderr.isTTY ?? false)) { + throw authRequired( + 'Sign-in needs an interactive terminal to open a browser and complete the OAuth flow.', + 'Run `poki auth login` from an interactive terminal.' + ) + } + return await interactiveLogin(log) +} + +// Legacy upload authentication. It intentionally keeps support for upload +// tokens and implicit browser login; API resource commands use readStoredAuth. +export async function auth (force = false, log: Log = console.log): Promise { + let config = force ? undefined : readStoredAuth() + + if (typeof process.env.POKI_ACCESS_TOKEN === 'string') { + console.warn('POKI_ACCESS_TOKEN has been deprecated, please use POKI_UPLOAD_TOKEN') + config = { + ...config, + access_type: 'Token', + access_token: process.env.POKI_ACCESS_TOKEN + } + } + + if (typeof process.env.POKI_UPLOAD_TOKEN === 'string') { + config = { + ...config, + access_type: 'Token', + access_token: process.env.POKI_UPLOAD_TOKEN + } + } + + if (config !== undefined) return config + // The legacy upload command historically initiated browser login implicitly, + // including when stdout was not a TTY. Keep that behavior here; the explicit + // auth login command retains its non-interactive guard. + return await interactiveLogin(log) +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..8351317 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,363 @@ +import yargs, { Argv } from 'yargs' + +import { ApiClient } from './api' +import { registerAuthCommands } from './commands/auth' +import { registerAudienceCommands } from './commands/audiences' +import { registerDataCommands } from './commands/data' +import { registerDiscoveryCommands } from './commands/discovery' +import { registerGameChangeRequestCommands } from './commands/game-change-requests' +import { registerGameEventCommands } from './commands/game-events' +import { registerGameCommands } from './commands/games' +import { registerNetlibLobbyCommands } from './commands/netlib-lobbies' +import { registerPlayerFitTestCommands } from './commands/player-fit-tests' +import { registerPlayerFeedbackQuestionCommands } from './commands/player-feedback-questions' +import { registerPlaytestRequestCommands } from './commands/playtest-requests' +import { registerPlaytestCommands } from './commands/playtests' +import { registerReviewCommands } from './commands/reviews' +import { registerVersionActivationCommands } from './commands/version-activations' +import { registerVersionCommands } from './commands/versions' +import { availableCommands, CommandSpec } from './docs/commands' +import { developerPermissionRequirements } from './developer-permissions' +import { CliError, safeApiErrorResponse } from './errors' +import { leadingCommandSpec, resolveHelp } from './help' +import { registerLegacyCommands } from './legacy' +import { requestedFormat, writeError, writeStructured } from './output' +import { projectConfigError } from './project' +import { CLI_VERSION } from './version' +import { UpdateCoordinator } from './update' + +function cliArguments (args: string[]): string[] { + // The pre-expansion yargs --version option was global: it short-circuited + // before command dispatch when placed first, or anywhere in the legacy + // upload invocation. Preserve its boolean forms too: a false value removed + // the option and continued with the upload, while the last value won. + // New commands use --version as a resource option, so only normalize a + // leading global probe or the deprecated top-level upload command. + if (args.length === 1 && args[0] === '-v') return ['version'] + + const optionEnd = args.includes('--') ? args.indexOf('--') : args.length + const versionToken = (index: number): { value: boolean, consumed: number } | undefined => { + const argument = args[index] + if (argument === '--no-version') return { value: false, consumed: 1 } + if (argument.startsWith('--version=')) { + return { value: argument === '--version=true', consumed: 1 } + } + if (argument !== '--version') return undefined + + const value = args[index + 1] + if (index + 1 < optionEnd && (value === 'true' || value === 'false')) { + return { value: value === 'true', consumed: 2 } + } + return { value: true, consumed: 1 } + } + + let leadingEnd = 0 + let leadingVersion: boolean | undefined + while (leadingEnd < optionEnd) { + const token = versionToken(leadingEnd) + if (token === undefined) break + leadingVersion = token.value + leadingEnd += token.consumed + } + + // yargs allowed upload's own options to precede its command token. Skip + // their values when identifying that shape so an option value named + // "upload" cannot accidentally turn a modern invocation into legacy mode. + const valueOptions = new Set([ + '--game', '-g', + '--build-dir', '--buildDir', '-b', + '--name', '-n', + '--notes', '-o' + ]) + const booleanOptions = new Set([ + '--make-public', '--makePublic', '-l', + '--disable-image-compression', '--disableImageCompression', '-i', + '--no-make-public', '--no-makePublic', + '--no-disable-image-compression', '--no-disableImageCompression' + ]) + let commandProbe = 0 + let legacyUpload = false + while (commandProbe < optionEnd) { + const token = versionToken(commandProbe) + if (token !== undefined) { + commandProbe += token.consumed + continue + } + + const argument = args[commandProbe] + if (argument === 'upload') { + legacyUpload = true + break + } + const equals = argument.indexOf('=') + const option = equals === -1 ? argument : argument.slice(0, equals) + if (valueOptions.has(option)) { + commandProbe += equals === -1 ? 2 : 1 + continue + } + if (booleanOptions.has(option)) { + const value = args[commandProbe + 1] + commandProbe += equals === -1 && (value === 'true' || value === 'false') ? 2 : 1 + continue + } + break + } + + let versionCompatibleArgs: string[] + if (legacyUpload) { + const withoutVersion: string[] = [] + let uploadVersion = leadingVersion + for (let index = 0; index < args.length;) { + const token = index < optionEnd ? versionToken(index) : undefined + if (token === undefined) { + withoutVersion.push(args[index]) + index += 1 + } else { + uploadVersion = token.value + index += token.consumed + } + } + versionCompatibleArgs = uploadVersion === true ? ['version'] : withoutVersion + } else if (leadingVersion === true) { + versionCompatibleArgs = ['version'] + } else if (leadingVersion === false) { + versionCompatibleArgs = args.slice(leadingEnd) + } else { + versionCompatibleArgs = args + } + + const normalized: string[] = [] + for (let index = 0; index < versionCompatibleArgs.length; index += 1) { + const argument = versionCompatibleArgs[index] + // A descending JSON:API sort starts with `-`, which yargs would otherwise + // interpret as a cluster of short options. Keep the documented + // `--sort -created_at` form usable as well as `--sort=-created_at`. + if (argument === '--sort' && /^-[^-]/.test(versionCompatibleArgs[index + 1] ?? '')) { + normalized.push(`--sort=${versionCompatibleArgs[index + 1]}`) + index += 1 + } else { + normalized.push(argument) + } + } + return normalized +} + +function editDistance (a: string, b: string): number { + const previous = Array.from({ length: b.length + 1 }, (_value, index) => index) + for (let i = 1; i <= a.length; i++) { + let diagonal = previous[0] + previous[0] = i + for (let j = 1; j <= b.length; j++) { + const substitution = diagonal + (a[i - 1] === b[j - 1] ? 0 : 1) + diagonal = previous[j] + previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, substitution) + } + } + return previous[b.length] +} + +// An unknown command embeds the surrounding command index and close matches +// so a caller can self-correct from the error alone, mirroring how +// MISSING_INPUT embeds the full contract. +function unknownCommandError (reason: string, unknownNames: string, args: string[]): CliError { + const { groupPath, commands: available } = availableCommands(args) + const attempted = unknownNames.split(', ') + const suggestions = available + .filter(candidate => { + const leaf = candidate.path.split(' ').pop() ?? '' + return attempted.some(name => editDistance(name, leaf) <= 2 || leaf.includes(name) || name.includes(leaf)) + }) + .map(candidate => candidate.path) + .slice(0, 3) + return new CliError('INVALID_INPUT', reason, 2, { + details: { + ...(suggestions.length === 0 ? {} : { suggestions }), + available_commands: available + }, + hint: groupPath.length === 0 + ? 'Run `poki help` for the command index or `poki help --search TEXT` to search it.' + : `Run \`poki help ${groupPath.join(' ')}\` for this group's actions.` + }) +} + +function clarifyUnknownArguments (message: string, args: string[]): string { + const match = /^(Unknown arguments?): (.+)$/.exec(message) + if (match === null) return message + + const names = match[2].split(', ') + const clarified = names.map(name => { + const original = args.find(argument => { + if (!argument.startsWith('-')) return false + const option = argument.split('=', 1)[0] + const optionName = option.replace(/^--?(?:no-)?/, '') + const camelName = optionName.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase()) + return optionName === name || camelName === name + }) + return original?.split('=', 1)[0] ?? name + }) + const unique = [...new Set(clarified)] + return `Unknown argument${unique.length === 1 ? '' : 's'}: ${unique.join(', ')}` +} + +// An unknown option embeds the command's declared options and close matches, +// mirroring how unknown commands embed the command index. +function unknownArgumentError (message: string, args: string[]): CliError { + const clarified = clarifyUnknownArguments(message, args) + const unknown = /^Unknown arguments?: (.+)$/.exec(clarified) + const spec = invocationSpec(args) + const options = (spec?.options ?? []).map(option => option.name) + if (unknown === null || spec === undefined || options.length === 0) { + return new CliError('INVALID_INPUT', clarified, 2) + } + const attempted = unknown[1].split(', ').map(name => name.replace(/^--?/, '')) + const suggestions = [...new Set(attempted.flatMap(name => { + return options.filter(candidate => editDistance(name, candidate.replace(/^--/, '')) <= 2) + }))].slice(0, 3) + return new CliError('INVALID_INPUT', clarified, 2, { + details: { + ...(suggestions.length === 0 ? {} : { suggestions }), + available_options: options + }, + hint: `Run \`poki help ${spec.path.join(' ')}\` for this command's full contract.` + }) +} + +function invocationSpec (args: string[]): CommandSpec | undefined { + return leadingCommandSpec(cliArguments(args))?.spec +} + +function permissionDeniedWithCommandContext (error: unknown, args: string[]): unknown { + if (!(error instanceof CliError) || error.code !== 'PERMISSION_DENIED' || error.status !== 403) return error + + const spec = invocationSpec(args) + if (spec === undefined) return error + + const command = `poki ${spec.path.join(' ')}` + const permissionCodes = spec.permission_codes ?? [] + return new CliError( + 'PERMISSION_DENIED', + `The Poki API denied \`${command}\` because the current credentials do not have permission for this operation or resource scope.`, + 4, + { + status: 403, + details: { + command, + permission_codes: permissionCodes, + permission_requirements: developerPermissionRequirements(permissionCodes), + ...(spec.permission_logic === undefined ? {} : { permission_logic: spec.permission_logic }), + api_response: safeApiErrorResponse(error.details) + }, + retryable: false, + requestId: error.requestId, + retryAfter: error.retryAfter, + hint: `Run \`poki whoami\` to inspect effective CLI permissions and \`poki help ${spec.path.join(' ')}\` to inspect this command. Ownership, team flags, account restrictions, and developer-support grants are evaluated by the backend.` + } + ) +} + +// `version` and `help` cannot come from a command group: their handlers close +// over the raw argv that buildCli was given. Registering them through one +// exported function keeps the parity probe recording the real declarations +// instead of a hand-copied mirror that can drift from this file. +export function registerBuiltinCommands (cli: Argv, args: string[]): Argv { + return cli + .command('version', 'Print the poki-cli version', version => version, () => { + process.stdout.write(`${CLI_VERSION}\n`) + }) + .command('help [command..]', 'Show help for a command path such as `poki help games create`', help => help + .positional('command', { describe: 'Nested command path', type: 'string', array: true }) + .option('all', { describe: 'Return a compact manifest for every command', type: 'boolean' }) + .option('full', { describe: 'With --all, include each command\'s complete input schema', type: 'boolean' }) + .option('search', { describe: 'Search command paths, summaries, permissions, and options', type: 'string' }) + .option('format', { describe: 'Structured output encoding', choices: ['toon', 'json'] as const, default: 'toon' }), () => { + // resolveHelp answers every help invocation before yargs dispatches, so + // this handler exists for the declaration alone. It still renders the + // document rather than returning silently: a routing gap must never + // become an empty successful response on the discovery surface. + const resolution = resolveHelp(args) + if (resolution === undefined) throw new CliError('UNEXPECTED_ERROR', 'Help could not be resolved for this invocation.', 5) + writeStructured(resolution.document, resolution.format) + }) +} + +export function registerRootCommands (cli: Argv, api: ApiClient): Argv { + cli = registerLegacyCommands(cli) + cli = registerAuthCommands(cli) + cli = registerAudienceCommands(cli) + cli = registerDiscoveryCommands(cli, api) + cli = registerGameCommands(cli, api) + cli = registerVersionCommands(cli, api) + cli = registerVersionActivationCommands(cli, api) + cli = registerPlaytestCommands(cli, api) + cli = registerPlaytestRequestCommands(cli, api) + cli = registerPlayerFitTestCommands(cli, api) + cli = registerReviewCommands(cli, api) + cli = registerGameChangeRequestCommands(cli, api) + cli = registerGameEventCommands(cli, api) + cli = registerPlayerFeedbackQuestionCommands(cli, api) + cli = registerNetlibLobbyCommands(cli, api) + return registerDataCommands(cli, api) +} + +export function buildCli (args: string[], api = new ApiClient()): Argv { + let cli = registerBuiltinCommands(yargs(cliArguments(args)) + .scriptName('poki') + .usage('$0 [options]\n\nManage Poki for Developers resources and analytics with machine-readable API commands.') + .version(false) + // resolveHelp answers every help request before yargs; yargs' built-in + // --help must stay disabled or a value-position --help (`--team --help`) + // would leak unstructured plain-text usage with exit 0. + .help(false), args) + + cli = registerRootCommands(cli, api) + + return cli + .demandCommand(1, 'Choose a command. Run `poki help` to see all commands.') + .strictCommands() + .strictOptions() + // recommendCommands is deliberately absent: its bare "Did you mean X?" + // message would bypass unknownCommandError, which embeds suggestions AND + // the command index for self-correction. + .showHelpOnFail(false) + .exitProcess(false) + .fail((message, error) => { + if (error instanceof CliError) throw error + // A yargs validation failure arrives with a message; a command handler + // exception arrives with only the thrown error and must surface as + // UNEXPECTED_ERROR with exit 5, not as the caller's input mistake. + const validationMessage = typeof message === 'string' && message !== '' ? message : undefined + if (validationMessage === undefined && error !== undefined) throw error + const reason = validationMessage ?? 'Invalid command input.' + if (/^Missing required arguments?: .*\bgame\b/.test(reason)) { + const configError = projectConfigError() + if (configError !== undefined) throw configError + throw new CliError('INVALID_INPUT', 'A game ID is required and no project game_id is configured.', 2, { + details: { option: '--game' }, + hint: 'Pass --game GAME_ID, or run `poki init --game GAME_ID` to configure this directory. `poki games list` shows visible game IDs.' + }) + } + const unknownCommand = /^Unknown commands?: (.+)$/.exec(reason) + if (unknownCommand !== null) throw unknownCommandError(reason, unknownCommand[1], args) + throw unknownArgumentError(reason, args) + }) +} + +export async function runCli (args: string[], api?: ApiClient): Promise { + const updates = new UpdateCoordinator(CLI_VERSION, requestedFormat(args)) + const client = api ?? new ApiClient() + client.addBeforeFirstRequestHook(updates.beforeFirstRequest) + try { + const help = resolveHelp(args) + if (help !== undefined) { + writeStructured(help.document, help.format) + return 0 + } + await buildCli(args, client).parseAsync() + await updates.commandSucceeded() + return 0 + } catch (error) { + const reportedError = permissionDeniedWithCommandContext(error, args) + writeError(reportedError, requestedFormat(args)) + return reportedError instanceof CliError ? reportedError.exitCode : 5 + } +} diff --git a/src/commands/async-create.ts b/src/commands/async-create.ts new file mode 100644 index 0000000..54c2b59 --- /dev/null +++ b/src/commands/async-create.ts @@ -0,0 +1,138 @@ +import { CliError, safeErrorCause } from '../errors' +import { ResourceResult } from '../jsonapi' +import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_WAIT_TIMEOUT_MS } from '../timeouts' +import { PollOutcome, withWaitMeta } from './polling' +import { render } from './rendering' +import { isMalformedSuccessfulMutation } from './resource-responses' + +// A create that a caller may follow with --wait has one shared failure +// boundary: the resource can already exist while the step that should have +// observed it fails. Replaying the mutation would duplicate the resource, so +// every such command reports the same non-retryable envelope carrying the +// created resource and an executable inspect-or-resume recovery. + +export interface RecoveryAction { + action: string + arguments: string[] +} + +export interface AsyncCreateContract { + errorCode: string + // Names the details keys: `_created`, `created_`, + // `created__id`, and `inspect_created_`. + noun: string + missingId: { message: string, hint: string } + pollFailed: { message: string, hint: string } + inspect: (argv: Record) => RecoveryAction + resumePoll: (createdId: string, argv: Record) => RecoveryAction +} + +// Resuming a poll repeats the caller's own pacing rather than the defaults. +export function pollArguments (argv: Record): string[] { + return [ + '--poll-interval-ms', String(argv.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS), + '--wait-timeout-ms', String(argv.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS) + ] +} + +export function asyncCreateWaitFailure ( + contract: AsyncCreateContract, + error: unknown, + createdResource: Record | undefined, + createdId: string | undefined, + argv: Record +): CliError { + const original = error instanceof CliError ? error : undefined + const outcome = createdId === undefined ? contract.missingId : contract.pollFailed + const recovery = createdId === undefined + ? { [`inspect_created_${contract.noun}`]: contract.inspect(argv) } + : { resume_poll: contract.resumePoll(createdId, argv) } + return new CliError(contract.errorCode, outcome.message, original?.exitCode ?? 5, { + status: original?.status, + retryable: false, + requestId: original?.requestId, + retryAfter: original?.retryAfter, + hint: outcome.hint, + details: { + [`${contract.noun}_created`]: true, + ...(createdId === undefined ? {} : { [`created_${contract.noun}_id`]: createdId }), + ...(createdResource === undefined ? {} : { [`created_${contract.noun}`]: createdResource }), + recovery: Object.fromEntries(Object.entries(recovery) + .map(([key, value]) => [key, { action: value.action, command: 'poki', arguments: value.arguments }])), + cause: safeErrorCause(error) + } + }) +} + +interface MutationResponse { + body: unknown + status: number +} + +export interface AsyncCreateOptions { + contract: AsyncCreateContract + argv: Record + send: () => Promise + normalize: (response: MutationResponse, onRecoverySnapshot: (result: ResourceResult) => void) => ResourceResult + // Returns the created ID or throws the command's own INVALID_API_RESPONSE. + createdIdOf: (normalized: ResourceResult, response: MutationResponse) => string + // versions upload proves the build reached the requested game from the + // create response alone, so it validates the identity even without --wait. + // A question ID is only needed to poll generation. + requireCreatedId: 'always' | 'when_waiting' + // Recovery projections: the first reads the recovery snapshot of an + // otherwise malformed document, the second the normalized created resource. + recoveryFromSnapshot: (data: unknown) => Record | undefined + recoveryFromNormalized: (data: unknown) => Record | undefined + poll: (createdId: string) => Promise +} + +export async function createThenWait (options: AsyncCreateOptions): Promise { + const { argv } = options + const waiting = argv.wait === true + const failure = (error: unknown, created: Record | undefined, createdId?: string): CliError => + asyncCreateWaitFailure(options.contract, error, created, createdId, argv) + + const response = await options.send().catch((error: unknown) => { + if (waiting && isMalformedSuccessfulMutation(error)) throw failure(error, undefined) + throw error + }) + + let normalized: ResourceResult + let snapshot: ResourceResult | undefined + try { + normalized = options.normalize(response, result => { + snapshot = result + }) + } catch (error) { + if (waiting) throw failure(error, options.recoveryFromSnapshot(snapshot?.data)) + throw error + } + + let createdId: string | undefined + if (waiting || options.requireCreatedId === 'always') { + try { + createdId = options.createdIdOf(normalized, response) + } catch (error) { + if (waiting) throw failure(error, options.recoveryFromNormalized(normalized.data)) + throw error + } + } + + if (!waiting || createdId === undefined) { + render(argv.raw === true ? response.body : normalized, argv) + return + } + + let outcome: PollOutcome + try { + outcome = await options.poll(createdId) + } catch (error) { + // A remote operation that is still running or has genuinely failed keeps + // its own contract; only an unexplained polling failure needs the + // created-resource recovery envelope. + if (error instanceof CliError && (error.code === 'WAIT_TIMEOUT' || error.code === 'ASYNC_OPERATION_FAILED')) throw error + throw failure(error, options.recoveryFromNormalized(normalized.data), createdId) + } + render(withWaitMeta(outcome), argv) +} diff --git a/src/commands/audience-input.ts b/src/commands/audience-input.ts new file mode 100644 index 0000000..e7faff4 --- /dev/null +++ b/src/commands/audience-input.ts @@ -0,0 +1,71 @@ +import { inputError } from '../errors' +import { asStrings } from './common' + +export const deviceCategories = ['any', 'desktop', 'mobile'] as const +export const audienceOrientations = ['both', 'portrait', 'landscape'] as const + +// A category ceiling is one command's product rule, so the command supplies +// both the number and the message rather than this shared module naming a +// command in errors raised for every audience surface. +export interface CategoryLimit { + max: number + message: string +} + +interface AudienceInputOptions { + defaults?: boolean + categoryLimit?: CategoryLimit +} + +function categoryIDsValue (value: unknown, limit?: CategoryLimit): string | undefined { + if (value === undefined) return undefined + const values = asStrings(value) ?? [] + if (values.some(category => !/^\d+$/.test(category))) { + throw inputError('--category values must be non-negative integer IDs.') + } + if (limit !== undefined && values.length > limit.max) throw inputError(limit.message) + return values.join(',') +} + +export function audienceInputFromFlags ( + argv: Record, + options: AudienceInputOptions = {} +): Record { + const data: Record = {} + if (options.defaults === true || argv.deviceCategory !== undefined) { + data.device_category = argv.deviceCategory ?? 'any' + } + if (options.defaults === true || argv.orientation !== undefined) { + data.orientation = argv.orientation ?? 'both' + } + const categories = categoryIDsValue(argv.category, options.categoryLimit) + if (options.defaults === true || categories !== undefined) { + data.categories = categories ?? '' + } + return data +} + +export function applyAudienceInputDefaults (data: Record): void { + data.device_category ??= 'any' + data.orientation ??= 'both' + data.categories ??= '' +} + +export function validateAudienceInput ( + data: Record, + options: Pick = {} +): void { + if (typeof data.device_category !== 'string' || !deviceCategories.includes(data.device_category as typeof deviceCategories[number])) { + throw inputError('device_category must be any, desktop, or mobile.') + } + if (typeof data.orientation !== 'string' || !audienceOrientations.includes(data.orientation as typeof audienceOrientations[number])) { + throw inputError('orientation must be both, portrait, or landscape.') + } + if (typeof data.categories !== 'string' || (data.categories !== '' && !/^\d+(,\d+)*$/.test(data.categories))) { + throw inputError('categories must be a comma-separated list of integer IDs.') + } + const limit = options.categoryLimit + if (limit !== undefined && data.categories !== '' && data.categories.split(',').length > limit.max) { + throw inputError(limit.message) + } +} diff --git a/src/commands/audiences.ts b/src/commands/audiences.ts new file mode 100644 index 0000000..44c57d8 --- /dev/null +++ b/src/commands/audiences.ts @@ -0,0 +1,35 @@ +import type { Argv } from 'yargs' + +import { audienceCatalog } from '../audiences' +import { render, withFormatOption } from './common' + +export function registerAudienceCommands (yargs: Argv): Argv { + return yargs.command('audiences', 'Discover bundled Poki content-category names and IDs without API access', audiences => audiences + .command('list', 'List bundled content-category names, IDs, and testing availability', list => withFormatOption(list) + .option('testing-only', { + describe: 'Return only categories enabled for Playtest and Player Fit targeting', + type: 'boolean', + default: false + }), argv => { + const data = argv.testingOnly + ? audienceCatalog.filter(audience => audience.enabled_for_testing) + : audienceCatalog + render({ + data, + meta: { + total: data.length, + testing_only: argv.testingOnly, + bundled_snapshot: true, + snapshot_advisory: true, + mutation_backend_authoritative: true, + refresh_requires_cli_update: true, + usage: { + games: '--suggested-category NAME', + playtest_requests: '--category ID', + player_fit_tests: '--category ID' + } + } + }, argv) + }) + .demandCommand(1, 'Choose audiences list.'), () => {}) +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..c92bd97 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,36 @@ +import type { Argv } from 'yargs' + +import { getAuthStatus, login, logoutStoredAuth } from '../auth' +import { inputError } from '../errors' +import { withFormatOption } from './common' +import { structuredFormat, writeStructured } from '../output' + +export function registerAuthCommands (yargs: Argv): Argv { + return yargs.command('auth', 'Manage the OAuth credentials used by Poki API commands', auth => auth + .command('login', 'Open the Poki sign-in flow and save OAuth credentials', loginCommand => withFormatOption(loginCommand), async argv => { + await login(message => process.stderr.write(`${message}\n`)) + writeStructured(getAuthStatus(), structuredFormat(argv.format)) + }) + .command('status', 'Describe saved authentication without printing any token', status => withFormatOption(status), argv => { + writeStructured(getAuthStatus(), structuredFormat(argv.format)) + }) + .command('logout', 'Remove saved OAuth credentials from this computer', logout => withFormatOption(logout) + .option('dry-run', { + describe: 'Preview the credential-file deletion without changing it', + type: 'boolean', + default: false + }) + .option('yes', { + describe: 'Confirm deletion of the saved credential file', + type: 'boolean', + default: false + }), argv => { + if (argv.dryRun) { + writeStructured({ operation: 'delete_saved_oauth_credentials', dry_run: true }, structuredFormat(argv.format)) + return + } + if (!argv.yes) throw inputError('Deleting saved OAuth credentials requires --yes. Use --dry-run to preview it.') + writeStructured({ logged_out: logoutStoredAuth() }, structuredFormat(argv.format)) + }) + .demandCommand(1, 'Choose auth login, auth status, or auth logout.'), () => {}) +} diff --git a/src/commands/command-options.ts b/src/commands/command-options.ts new file mode 100644 index 0000000..e86ddc3 --- /dev/null +++ b/src/commands/command-options.ts @@ -0,0 +1,291 @@ +import type { Argv, Options } from 'yargs' + +import { + dryRunOption, + fieldsOption, + filterOption, + formatOption, + fullOption, + type HelpOption, + listFormatOption, + paginationOptions, + rawOption, + sortOption +} from '../docs/commands' +import { inputError } from '../errors' +import { readStructuredSource, requireAllowedFields } from '../input' +import type { ListCapabilities } from '../list-capabilities' +import { + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_UPLOAD_TIMEOUT_MS, + MAX_TIMEOUT_MS, + parseTimeoutMilliseconds, + TIMEOUT_MILLISECONDS_RANGE +} from '../timeouts' +import { ResourceListKind, validateListViewFields } from '../views' +import { render } from './rendering' + +export interface MutationBehavior { + destructive?: boolean + nonAtomic?: boolean + sideEffects?: string[] +} + +// buildCli disables yargs help and resolveHelp answers every help request, so a +// yargs `describe` can never reach a user: the HelpOption spec is the sole +// documentation surface. Declaring a shared option here therefore means copying +// its name, description, choices and default out of the spec, and a copy can +// disagree. Generate the declaration from the spec instead. +// +// What the spec cannot express stays with the caller: a documented type like +// "positive integer" is prose, and repeatability says nothing about whether one +// occurrence takes exactly one token. Both are load-bearing for parsing, and +// neither is uniform - --filter and --sort accept one string each, while +// repeatable options such as --tag and --event deliberately do not set nargs. +export function applySpecOption (yargs: Argv, spec: HelpOption, declaration: Options = {}): Argv { + const declared: Options = { describe: spec.description } + if (spec.type === 'boolean') declared.type = 'boolean' + if (spec.repeatable === true) declared.type = 'array' + if (spec.values !== undefined) declared.choices = spec.values + if (spec.default !== undefined) declared.default = spec.default + return yargs.option(spec.name.replace(/^--/, ''), { ...declared, ...declaration }) +} + +// --game shares nothing but its name with the spec: every caller supplies its +// own description, and requiredness and default are resolved from the project +// configuration at registration time. The historical legacy-upload quirk rides +// on that resolution, so it stays declared here rather than generated. +export function withDefaultGameOption ( + yargs: Argv, + projectGameId: string | undefined, + description = 'Game ID that scopes this command' +): Argv { + return yargs.option('game', { + describe: `${description}; defaults to game_id from the current project configuration`, + type: 'string', + demandOption: projectGameId === undefined, + ...(projectGameId === undefined ? {} : { default: projectGameId }) + }) +} + +export function withFormatOption (yargs: Argv): Argv { + return applySpecOption(yargs, formatOption) +} + +// --timeout-ms keeps a hand-written declaration: three documented variants say +// the same thing about a different budget, and upload and download share the +// 300000 ms default while documenting it differently, so the applicable spec +// cannot be selected from this function's only parameter. The declared text is +// what parity.test.ts checks the documented budget against. +export function withTimeoutOption (yargs: Argv, defaultMilliseconds = DEFAULT_REQUEST_TIMEOUT_MS): Argv { + return yargs + .option('timeout-ms', { + describe: `Maximum time for each API request in milliseconds; accepts an ${TIMEOUT_MILLISECONDS_RANGE}; defaults to POKI_API_TIMEOUT_MS or ${String(defaultMilliseconds)}`, + type: 'number' + }) + .check(argv => { + if (argv.timeoutMs !== undefined && parseTimeoutMilliseconds(argv.timeoutMs) === undefined) { + throw inputError(`--timeout-ms must be a positive integer no greater than ${String(MAX_TIMEOUT_MS)}.`) + } + return true + }) +} + +export function withRequestOptions (yargs: Argv): Argv { + return withTimeoutOption(withFormatOption(yargs)) +} + +export function withOutputOptions (yargs: Argv): Argv { + return applySpecOption(withRequestOptions(yargs), rawOption) +} + +export function withUploadOutputOptions (yargs: Argv): Argv { + return applySpecOption(withTimeoutOption(withFormatOption(yargs), DEFAULT_UPLOAD_TIMEOUT_MS), rawOption) +} + +// --yes keeps a hand-written declaration because its wording is the only use of +// `behavior` here; generating it from the single documented spec would leave +// every caller passing a parameter that no longer does anything. +export function withMutationOptions (yargs: Argv, behavior: MutationBehavior = {}): Argv { + return applySpecOption(yargs, dryRunOption) + .option('yes', { + describe: behavior.destructive === true || behavior.nonAtomic === true + ? 'Confirm the documented destructive or non-atomic operation; required unless --dry-run is used' + : 'Confirm without an interactive prompt; accepted for uniform automation', + type: 'boolean', + default: false + }) +} + +// The standard composition for a game-scoped mutation command. Spelled out per +// command, one of the three layers can go missing, which would advertise a +// mutation without --game, --dry-run and --yes, or --format and --timeout-ms. +export function withGameMutationOptions ( + yargs: Argv, + projectGameId: string | undefined, + description: string, + behavior: MutationBehavior = {} +): Argv { + return withDefaultGameOption(withMutationOptions(withOutputOptions(yargs), behavior), projectGameId, description) +} + +// Same composition for actions that report a CLI-synthesized result and +// therefore declare no --raw backend document. +export function withGameActionOptions ( + yargs: Argv, + projectGameId: string | undefined, + description: string, + behavior: MutationBehavior = {} +): Argv { + return withDefaultGameOption(withMutationOptions(withRequestOptions(yargs), behavior), projectGameId, description) +} + +// nargs: 1 keeps yargs from swallowing the following argument into --data. +export function withDataOption (yargs: Argv, describe: string): Argv { + return yargs.option('data', { describe, type: 'string', nargs: 1 }) +} + +export function requireConfirmation (args: Record, action: string): void { + if (args.dryRun === true || args.yes === true) return + throw inputError(`${action} requires --yes. Use --dry-run to inspect the resolved operation first.`, { + confirmation_flag: '--yes', + preview_flag: '--dry-run' + }) +} + +export function requestTimeout (args: Record): number | undefined { + return args.timeoutMs === undefined ? undefined : parseTimeoutMilliseconds(args.timeoutMs) +} +export function mutationPreview ( + method: 'POST' | 'PATCH' | 'DELETE', + path: string, + body: unknown, + args: Record, + behavior: MutationBehavior = {} +): boolean { + if (args.dryRun !== true) return false + render({ + dry_run: true, + contacted_api: false, + validation: { + scope: 'local_input_only', + local_input_validated: true, + backend_mutation_validated: false, + mutation_permissions_validated: false, + resource_state_validated: false + }, + executable: 'unknown', + request: { + method, + path, + ...(body === undefined ? {} : { body }) + }, + // risk mirrors the command's documented classification from poki help + // (destructive outranks non_atomic); the booleans below describe the + // resolved invocation precisely. + risk: behavior.destructive === true ? 'destructive' : behavior.nonAtomic === true ? 'non_atomic' : 'mutation', + destructive: behavior.destructive ?? false, + non_atomic: behavior.nonAtomic ?? false, + side_effects: behavior.sideEffects ?? [] + }, args) + return true +} + +// View selection for commands that return a resource list in one response +// (relationship-backed lists without server pagination). +export function withListViewOptions (yargs: Argv, kind: ResourceListKind): Argv { + // listFormatOption re-declares --format with the csv choice the view adds. + let command = applySpecOption(withOutputOptions(yargs), listFormatOption) + command = applySpecOption(command, fullOption) + command = applySpecOption(command, fieldsOption, { type: 'string' }) + return command + .check(argv => { + // Boolean(), not === true: generating the declaration from the spec drops + // the yargs type inference that used to make argv.full a boolean here, and + // the check must keep testing exactly the same truthiness it always did. + if (argv.raw === true && Boolean(argv.full)) throw inputError('--raw cannot be combined with --full.') + if (argv.raw === true && argv.fields !== undefined) throw inputError('--raw cannot be combined with --fields.') + if (argv.raw === true && argv.format === 'csv') throw inputError('--format csv cannot be combined with --raw.') + if (Boolean(argv.full) && argv.fields !== undefined) throw inputError('--full cannot be combined with --fields.') + if (typeof argv.fields === 'string') { + const fields = argv.fields.split(',').map(field => field.trim()) + if (fields.length === 0 || fields.some(field => !/^[A-Za-z0-9_]+$/.test(field))) { + throw inputError('--fields must be a comma-separated list of top-level field names.') + } + validateListViewFields(kind, argv.fields) + } + return true + }) +} + +// One string per occurrence. Without nargs yargs swallows the following token +// into the array, so `--filter a=b games` would silently lose the command. +const repeatedStringValue: Options = { string: true, nargs: 1 } + +export function withListOptions (yargs: Argv, capabilities: ListCapabilities, kind: ResourceListKind): Argv { + let command = withListViewOptions(yargs, kind) + if (capabilities.filter) command = applySpecOption(command, filterOption, repeatedStringValue) + if (capabilities.sort) command = applySpecOption(command, sortOption, repeatedStringValue) + if (capabilities.pagination) { + // Every documented pagination bound is a count except the boolean --all, + // whose kind the spec already carries. + for (const spec of paginationOptions) { + command = applySpecOption(command, spec, spec.type === 'boolean' ? {} : { type: 'number' }) + } + } + return command.check(argv => { + if (capabilities.pagination) { + if (argv.raw === true && argv.all === true) throw inputError('--raw cannot be combined with --all.') + if (argv.all === true && argv.page !== 1) throw inputError('--all fetches every page from page 1; it cannot be combined with --page.') + if (!Number.isInteger(argv.page) || Number(argv.page) < 1) throw inputError('--page must be a positive integer.') + if (!Number.isInteger(argv.pageSize) || Number(argv.pageSize) < 1) throw inputError('--page-size must be a positive integer.') + if (argv.maxPages !== undefined && (!Number.isInteger(argv.maxPages) || Number(argv.maxPages) < 1)) throw inputError('--max-pages must be a positive integer.') + if (argv.maxItems !== undefined && (!Number.isInteger(argv.maxItems) || Number(argv.maxItems) < 1)) throw inputError('--max-items must be a positive integer.') + if (argv.all !== true && (argv.maxPages !== undefined || argv.maxItems !== undefined)) { + throw inputError('--max-pages and --max-items require --all.') + } + } + return true + }) +} +function suppliedFlags (argv: Record, names: readonly string[]): string[] { + return names.filter(name => argv[name] !== undefined) +} + +export function ensureDataExclusive (argv: Record, fieldNames: readonly string[]): void { + if (argv.data === undefined) return + const supplied = suppliedFlags(argv, fieldNames) + if (supplied.length > 0) { + throw inputError(`--data cannot be combined with field flags: ${supplied.map(name => `--${name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`).join(', ')}.`) + } +} + +export interface MutationInputFields { + // camelCase yargs names that --data replaces and therefore excludes. + flags: readonly string[] + // JSON:API attribute names a --data document may contain. The list is + // reported verbatim by the unsupported-field error, so its order is public. + fields: readonly string[] +} + +// Pairs each field flag with the attribute it supplies so one declaration +// answers both questions. Two hand-maintained lists can disagree, and a flag +// whose attribute is missing from the allowlist is advertised but unusable. +export function mutationInputFields (pairs: Readonly>): MutationInputFields { + return { flags: Object.keys(pairs), fields: Object.values(pairs) } +} + +// Every --data-capable mutation resolves its input the same way: --data and the +// field flags are mutually exclusive, --data supplies the complete field set, +// and the result may contain only allowed fields. Commands differ only in how +// they read their flags, so that step stays with the command. +export async function resolveMutationInput ( + argv: Record, + input: MutationInputFields, + fromFlags: () => Record +): Promise> { + ensureDataExclusive(argv, input.flags) + const data = argv.data === undefined ? fromFlags() : await readStructuredSource(String(argv.data)) + requireAllowedFields(data, input.fields) + return data +} diff --git a/src/commands/common.ts b/src/commands/common.ts new file mode 100644 index 0000000..4010c7f --- /dev/null +++ b/src/commands/common.ts @@ -0,0 +1,7 @@ +export * from './command-options' +export * from './downloads' +export * from './pagination' +export * from './paths' +export * from './polling' +export * from './rendering' +export * from './resource-responses' diff --git a/src/commands/data.ts b/src/commands/data.ts new file mode 100644 index 0000000..3ac2a2e --- /dev/null +++ b/src/commands/data.ts @@ -0,0 +1,489 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { requestTimeout, withFormatOption, withTimeoutOption } from './common' +import { findTable, snapshotWarnings, tableCatalog } from '../data/catalog' +import { dataRecipes, fillRecipe, findRecipe, recipeNamesForTable, recipeParameterNames, recipePlaceholders, type DataRecipe } from '../data/examples' +import { describeQueryTopic, includeResourceTypes, joinPolicy, queryTopics, resolvedSelectOutputName, validateDataQuery } from '../data/grammar' +import { dataMetrics, findMetric } from '../data/metrics' +import { CliError, inputError } from '../errors' +import { readStructuredSource } from '../input' +import { isRecord } from '../json' +import { jsonValueKind, normalizeJsonApi } from '../jsonapi' +import { structuredFormat, writeStructured } from '../output' +import { getProjectGameId } from '../project' +import { ANALYTICS_TIME_ZONE } from '../timezones' + +const provenance = { + time_zone: ANALYTICS_TIME_ZONE, + documentation: { + bundled: true, + external_sources_required: false, + command_contracts: 'poki help --all', + query_grammar: 'poki data describe all', + join_rules: 'poki data describe joins', + tables: 'poki data tables; poki data table TABLE; poki data column TABLE COLUMN', + metrics: 'poki data metrics; poki data metric NAME', + recipes: 'poki data recipes; poki data recipe NAME' + }, + api_authority: 'The bundled snapshot is informative; the deployed API remains authoritative for permissions, validation, and newer schema fields.' +} + +function withDataRequestOptions (yargs: Argv): Argv { + return withTimeoutOption(yargs) + .option('format', { + describe: 'Executed result encoding', + choices: ['toon', 'json', 'csv'] as const, + default: 'toon' + }) + .option('validate-only', { + describe: 'Validate locally and print the query without contacting the API', + type: 'boolean', + default: false + }) + .check(argv => { + if (argv.validateOnly === true && argv.format === 'csv') throw inputError('--format csv requires query execution.') + return true + }) +} + +function columnIndex (column: { name: string, type: string, description: string }): { + name: string + type: string + nullable: boolean + summary: string +} { + const nullable = column.type.startsWith('Nullable(') && column.type.endsWith(')') + return { + name: column.name, + type: nullable ? column.type.slice('Nullable('.length, -1) : column.type, + nullable, + summary: column.description + } +} + +// Curated recommendations are declared by each metric instead of inferred +// from matching column names: identically named measures can have incompatible +// populations or grains (custom-event rows are the canonical example). +function metricTableRecommendations (names: string[]): Array> { + return names.map(name => { + const table = findTable(name) + if (table === undefined) throw new Error(`Metric references unknown bundled table '${name}'.`) + return { name: table.name, grain: table.grain, population: table.population } + }) +} + +function normalizeIncluded (included: unknown): unknown { + if (!isRecord(included)) return {} + const normalized: Record = {} + for (const [type, resources] of Object.entries(included)) { + if (!includeResourceTypes.includes(type as typeof includeResourceTypes[number])) continue + if (!isRecord(resources)) { + normalized[type] = {} + continue + } + normalized[type] = Object.fromEntries(Object.entries(resources).map(([id, node]) => { + const identity = { type, id } + if (!isRecord(node) || node.type !== type || node.id !== id) return [id, identity] + try { + const resource = normalizeJsonApi({ data: node }).data + return [id, isRecord(resource) && resource.type === type && resource.id === id ? resource : identity] + } catch (error) { + return [id, identity] + } + })) + } + return normalized +} + +function analyticsResponseStructure (body: unknown): Record { + if (!isRecord(body)) return { document_type: jsonValueKind(body) } + return { + document_type: 'object', + total: { + present: Object.prototype.hasOwnProperty.call(body, 'total'), + type: jsonValueKind(body.total), + non_negative_integer: typeof body.total === 'number' && Number.isInteger(body.total) && body.total >= 0 + }, + header: { + present: Object.prototype.hasOwnProperty.call(body, 'header'), + type: jsonValueKind(body.header), + ...(Array.isArray(body.header) + ? { + length: body.header.length, + all_strings: body.header.every(column => typeof column === 'string'), + unique: new Set(body.header).size === body.header.length + } + : {}) + }, + rows: { + present: Object.prototype.hasOwnProperty.call(body, 'rows'), + type: jsonValueKind(body.rows), + ...(Array.isArray(body.rows) ? { length: body.rows.length, all_objects: body.rows.every(isRecord) } : {}) + } + } +} + +function invalidAnalyticsResponse (message: string, body: unknown): CliError { + return new CliError('INVALID_API_RESPONSE', message, 5, { + details: { + expected: { total: 'non-negative integer', header: 'unique string[]', rows: 'object[]' }, + received_structure: analyticsResponseStructure(body) + } + }) +} + +function freshnessOutputColumn (query: Record): string | undefined { + if (query.from !== 'table_update_times' || !Array.isArray(query.select)) return undefined + const statement = query.select.find(candidate => isRecord(candidate) && resolvedSelectOutputName({ field: candidate.field }) === 'last_updated_at' && candidate.aggregate === undefined && candidate.formula === undefined && candidate.function === undefined && candidate.constant === undefined) + if (!isRecord(statement)) return undefined + return resolvedSelectOutputName(statement) +} + +function structuredSnapshotWarnings (query: Record): Array<{ code: string, message: string, blocking: boolean }> { + return snapshotWarnings(query).map(message => ({ code: 'BUNDLED_SNAPSHOT_MISMATCH', message, blocking: false })) +} + +function normalizeDataResult (body: unknown, query: Record, recipeName?: string): unknown { + if (!isRecord(body)) throw invalidAnalyticsResponse('The analytics response was not an object.', body) + if (typeof body.total !== 'number' || !Number.isInteger(body.total) || body.total < 0 || !Array.isArray(body.header) || body.header.some(column => typeof column !== 'string') || new Set(body.header).size !== body.header.length || !Array.isArray(body.rows) || body.rows.some(row => !isRecord(row))) { + throw invalidAnalyticsResponse('The analytics response must contain total, header, and rows.', body) + } + + const totalRows = body.total + const header = body.header as string[] + const rows = body.rows as Array> + const normalizedRows = rows.map(row => Object.fromEntries(header.flatMap(column => Object.prototype.hasOwnProperty.call(row, column) ? [[column, row[column]]] : []))) + const returnedRows = normalizedRows.length + const limit = query.limit === undefined ? 10000 : Number(query.limit) + const offset = query.offset === undefined ? 0 : Number(query.offset) + // `total` is not always a count of result rows: for an ungrouped query whose + // selects are formula or function wrappers rather than bare aggregates, the + // backend's count query degrades to COUNT(*) over the source rows, so a + // complete one-row aggregate reports a total in the thousands. The row query + // is always LIMIT/OFFSET bounded, so a window that came back short of the + // requested limit is exhausted by construction whatever the total claims; + // only a full window can have more behind it. + const hasMore = returnedRows >= limit && offset + returnedRows < totalRows + const freshnessColumn = freshnessOutputColumn(query) + const freshnessReturned = freshnessColumn !== undefined && header.includes(freshnessColumn) && normalizedRows.length > 0 && normalizedRows.every(row => typeof row[freshnessColumn] === 'string' && String(row[freshnessColumn]).trim() !== '') + const warnings: Array<{ code: string, message: string, blocking: boolean }> = structuredSnapshotWarnings(query) + if (!freshnessReturned) { + warnings.push({ + code: 'FRESHNESS_NOT_CHECKED', + message: 'This query result does not establish source freshness; run poki data freshness separately.', + blocking: false + }) + } + return { + total: totalRows, + header: [...header], + rows: normalizedRows, + ...(body.included === undefined ? {} : { included: normalizeIncluded(body.included) }), + meta: { + evidence: { + query, + recipe: recipeName ?? null, + source: query.from, + requested: { limit, offset }, + returned: { rows: returnedRows, total_rows: totalRows }, + completeness: { + // Completeness is "started at the beginning and nothing follows", not + // "row count equals the reported total": the latter is false for + // every aggregate whose total counts source rows. + complete: offset === 0 && !hasMore, + has_more: hasMore, + omitted_before_offset: offset > 0 + }, + time_zone: ANALYTICS_TIME_ZONE, + freshness: freshnessReturned + ? { status: 'returned_in_rows' } + : { status: 'not_checked', command: 'poki data freshness' }, + warnings + } + } + } +} + +function requireResolvedQuery (query: Record, recipeName?: string): void { + // A recipe is checked against its own parameters; a user-authored query is + // checked against every bundled parameter name so a piped, unfilled recipe + // is still caught. Angle-bracket text that names no recipe parameter is an + // ordinary string value such as a like pattern. + const declared = recipeParameterNames(recipeName === undefined ? undefined : findRecipe(recipeName)) + const unresolved = recipePlaceholders(query).filter(name => declared.has(name)) + if (unresolved.length > 0) { + const reference = recipeName === undefined ? 'poki data recipe NAME' : `poki data recipe ${recipeName}` + throw inputError('The query contains unresolved recipe placeholders.', { + unresolved_placeholders: unresolved + }, `Run \`${reference}\` to see the recipe's parameters, then supply --team/--from-date/--to-date or --param NAME=VALUE.`) + } +} + +async function executeQuery ( + api: ApiClient, + query: Record, + argv: Record, + recipeName?: string +): Promise { + validateDataQuery(query) + requireResolvedQuery(query, recipeName) + if (argv.validateOnly === true) { + // Advisory snapshot cross-check: unknown tables or columns warn but never + // invalidate the query, because the deployed API remains authoritative. + const warnings = structuredSnapshotWarnings(query) + writeStructured({ + local_structure_valid: true, + api_validated: false, + executable: 'unknown', + query, + ...(warnings.length === 0 ? {} : { warnings }), + meta: { contacted_api: false, validation_scope: 'local_structure_only', provenance } + }, structuredFormat(argv.format)) + return + } + + const params = new URLSearchParams() + if (argv.format === 'csv') params.set('csv', '') + const response = await api.request({ + method: 'POST', + path: '/_data', + query: params, + body: query, + contentType: 'application/json', + accept: argv.format === 'csv' ? 'text/csv;base64' : 'application/json', + responseType: argv.format === 'csv' ? 'text' : 'json', + timeoutMs: requestTimeout(argv), + retrySafe: true + }) + + if (argv.format === 'csv') { + const encoded = String(response.body).trim() + // The endpoint answers text/csv;base64; anything else (a proxy error + // page, plain CSV) would decode to binary garbage on stdout. + if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new CliError('INVALID_API_RESPONSE', 'The analytics CSV response was not valid base64.', 5, { + details: { + expected: 'base64_text', + received: { kind: 'string', length: encoded.length } + } + }) + } + const csv = Buffer.from(encoded, 'base64').toString('utf8') + // An empty, whitespace-only, or 204-style body carries no header row. It + // would print as a successful zero-byte export, which an agent cannot tell + // apart from a real result. + if (csv.trim() === '') { + throw new CliError('INVALID_API_RESPONSE', 'The analytics CSV response contained no header row.', 5, { + details: { + expected: 'base64_text decoding to at least a header row', + received: { kind: 'string', length: encoded.length, decoded_length: csv.length } + } + }) + } + process.stdout.write(csv.endsWith('\n') ? csv : `${csv}\n`) + return + } + writeStructured(normalizeDataResult(response.body, query, recipeName), structuredFormat(argv.format)) +} + +// One calendar day in milliseconds; date arithmetic happens on UTC-noon +// anchors so DST shifts cannot move the calendar date. +function analyticsDate (daysAgo: number): string { + const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: ANALYTICS_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit' }) + const [year, month, day] = formatter.format(new Date()).split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day, 12) - daysAgo * 86400000).toISOString().slice(0, 10) +} + +function withRecipeParameters (yargs: Argv, includeExecution: boolean): Argv { + let command: Argv = yargs + .option('team', { describe: 'TEAM_ID parameter', type: 'string' }) + .option('game', { describe: 'GAME_ID parameter; defaults to project game_id', type: 'string' }) + .option('from-date', { describe: 'FROM_DATE in YYYY-MM-DD Europe/Amsterdam calendar time', type: 'string' }) + .option('to-date', { describe: 'TO_DATE in YYYY-MM-DD Europe/Amsterdam calendar time', type: 'string' }) + .option('last-days', { describe: 'Fill FROM_DATE and TO_DATE with the N complete Europe/Amsterdam days ending yesterday', type: 'number' }) + .option('from-datetime', { describe: 'FROM_DATETIME in YYYY-MM-DD HH:mm:ss Europe/Amsterdam local time', type: 'string' }) + .option('to-datetime', { describe: 'TO_DATETIME in YYYY-MM-DD HH:mm:ss Europe/Amsterdam local time', type: 'string' }) + .option('param', { describe: 'Additional or overriding recipe parameter in NAME=VALUE form; repeatable', type: 'array', string: true }) + .check(argv => { + if (argv.lastDays !== undefined) { + if (!Number.isInteger(argv.lastDays) || Number(argv.lastDays) < 1) throw inputError('--last-days must be a positive integer.') + if (argv.fromDate !== undefined || argv.toDate !== undefined) throw inputError('--last-days cannot be combined with --from-date or --to-date.') + } + return true + }) + if (includeExecution) command = withDataRequestOptions(command) + else command = withFormatOption(command) + return command +} + +function recipeParameters ( + argv: Record, + projectGameId: string | undefined, + accepted: Record +): Record { + const parameters: Record = {} + const mappings: Array<[string, string, string | undefined]> = [ + ['TEAM_ID', 'team', undefined], + ['GAME_ID', 'game', projectGameId], + ['FROM_DATE', 'fromDate', undefined], + ['TO_DATE', 'toDate', undefined], + ['FROM_DATETIME', 'fromDatetime', undefined], + ['TO_DATETIME', 'toDatetime', undefined] + ] + for (const [name, field, fallback] of mappings) { + const value = argv[field] ?? (accepted[name] === undefined ? undefined : fallback) + if (typeof value === 'string') parameters[name] = value + } + if (argv.lastDays !== undefined) { + if (accepted.FROM_DATE === undefined || accepted.TO_DATE === undefined) { + throw inputError('--last-days requires a recipe with FROM_DATE and TO_DATE parameters.', { accepted_parameters: Object.keys(accepted) }) + } + parameters.FROM_DATE = analyticsDate(Number(argv.lastDays)) + parameters.TO_DATE = analyticsDate(1) + } + for (const raw of Array.isArray(argv.param) ? argv.param.map(String) : []) { + const separator = raw.indexOf('=') + // An empty value is legal: some recipe parameters (e.g. LABEL) document + // an empty string as a meaningful input. + if (separator <= 0) throw inputError(`Invalid parameter '${raw}'. Use NAME=VALUE.`) + const name = raw.slice(0, separator) + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw inputError(`Invalid parameter name '${name}'. Use uppercase recipe parameter names.`) + parameters[name] = raw.slice(separator + 1) + } + for (const name of ['FROM_DATE', 'TO_DATE']) { + if (parameters[name] !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(parameters[name])) throw inputError(`${name} must use YYYY-MM-DD.`) + } + for (const name of ['FROM_DATETIME', 'TO_DATETIME']) { + if (parameters[name] !== undefined && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(parameters[name])) throw inputError(`${name} must use YYYY-MM-DD HH:mm:ss.`) + } + // Both formats sort lexicographically, so string comparison detects an + // inverted range that would otherwise silently return zero rows. + for (const [from, to] of [['FROM_DATE', 'TO_DATE'], ['FROM_DATETIME', 'TO_DATETIME']]) { + if (parameters[from] !== undefined && parameters[to] !== undefined && parameters[from] > parameters[to]) { + throw inputError(`${from} must not be after ${to}.`, { [from.toLowerCase()]: parameters[from], [to.toLowerCase()]: parameters[to] }) + } + } + return parameters +} + +function resolveRecipe ( + name: string, + argv: Record, + projectGameId: string | undefined +): { recipe: DataRecipe, query: Record } { + const recipe = findRecipe(name) + if (recipe === undefined) throw inputError(`Unknown data recipe '${name}'.`, { available_recipes: dataRecipes.map(recipe => recipe.name) }) + + const supplied = recipeParameters(argv, projectGameId, recipe.parameters) + const unknown = Object.keys(supplied).filter(parameter => recipe.parameters[parameter] === undefined) + if (unknown.length > 0) { + throw inputError('The recipe does not define one or more supplied parameters.', { + unknown_parameters: unknown, + accepted_parameters: Object.keys(recipe.parameters) + }) + } + + return { recipe, query: fillRecipe(recipe.query, supplied) } +} + +export function registerDataCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('data', 'Discover, validate, and execute Poki for Developers analytics queries', data => data + .command('query', 'Validate, execute, or export a complete structured query', query => withDataRequestOptions(query) + .option('query', { describe: 'Query JSON or TOON object, @file, or - for stdin', type: 'string', nargs: 1, demandOption: true }), async argv => { + await executeQuery(api, await readStructuredSource(argv.query, '--query'), argv) + }) + .command('run ', 'Resolve typed parameters in a bundled recipe, then validate or execute it', run => withRecipeParameters(run, true) + .positional('name', { describe: 'Recipe name returned by data recipes', type: 'string', demandOption: true }) + .option('limit', { describe: 'Override or set the query row limit', type: 'number' }) + .option('offset', { describe: 'Override or set the query row offset', type: 'number' }) + .check(argv => { + if (argv.limit !== undefined && (!Number.isInteger(argv.limit) || Number(argv.limit) < 1)) throw inputError('--limit must be a positive integer.') + if (argv.offset !== undefined && (!Number.isInteger(argv.offset) || Number(argv.offset) < 0)) throw inputError('--offset must be a non-negative integer.') + return true + }), async argv => { + const { query } = resolveRecipe(String(argv.name), argv, projectGameId) + if (argv.limit !== undefined) query.limit = Number(argv.limit) + if (argv.offset !== undefined) query.offset = Number(argv.offset) + await executeQuery(api, query, argv, String(argv.name)) + }) + .command('describe [topic]', 'Describe the analytics query grammar by topic', describe => withFormatOption(describe) + .positional('topic', { describe: 'Grammar topic; omit for compact index', choices: queryTopics, type: 'string' }), argv => { + writeStructured({ ...describeQueryTopic(argv.topic), provenance }, structuredFormat(argv.format)) + }) + .command('tables', 'List the existing bundled analytics table snapshot', tables => withFormatOption(tables) + .option('full', { describe: 'Include column counts and recipe names', type: 'boolean', default: false }), argv => { + writeStructured({ + data: tableCatalog.map(({ columns, ...table }) => argv.full ? { ...table, column_count: columns.length, recipes: recipeNamesForTable(table.name) } : table), + meta: { + total: tableCatalog.length, + top_level: tableCatalog.filter(table => table.top_level).length, + join_only: tableCatalog.filter(table => !table.top_level).length, + join_policy: joinPolicy, + date_time_zone: ANALYTICS_TIME_ZONE, + provenance + } + }, structuredFormat(argv.format)) + }) + .command('table ', 'Describe one existing analytics table and its compact column index', table => withFormatOption(table) + .positional('name', { describe: 'Exact bundled table name', type: 'string', demandOption: true }), argv => { + const found = findTable(argv.name) + if (found === undefined) throw inputError(`Unknown bundled table '${argv.name}'.`, { available_tables: tableCatalog.map(table => table.name) }) + writeStructured({ data: { ...found, columns: found.columns.map(columnIndex), examples: recipeNamesForTable(found.name) }, meta: { total: found.columns.length, date_time_zone: ANALYTICS_TIME_ZONE, provenance } }, structuredFormat(argv.format)) + }) + .command('column ', 'Describe one bundled analytics column', column => withFormatOption(column) + .positional('table', { describe: 'Exact bundled table name', type: 'string', demandOption: true }) + .positional('column', { describe: 'Exact column name', type: 'string', demandOption: true }), argv => { + const found = findTable(argv.table) + if (found === undefined) throw inputError(`Unknown bundled table '${argv.table}'.`, { available_tables: tableCatalog.map(table => table.name) }) + const foundColumn = found.columns.find(column => column.name === argv.column) + if (foundColumn === undefined) throw inputError(`Unknown column '${argv.column}' on table '${argv.table}'.`, { available_columns: found.columns.map(column => column.name) }) + writeStructured({ + data: { table: found.name, table_description: found.description, top_level: found.top_level, ...(found.join_on === undefined ? {} : { join_on: found.join_on }), column: columnIndex(foundColumn), recipes: recipeNamesForTable(found.name) }, + meta: { date_time_zone: ANALYTICS_TIME_ZONE, provenance } + }, structuredFormat(argv.format)) + }) + .command('metrics', 'List dashboard metric semantics and curated compatible source tables', metrics => withFormatOption(metrics) + .option('full', { describe: 'Include complete formula objects and table grain recommendations', type: 'boolean', default: false }), argv => { + writeStructured({ + data: dataMetrics.map(({ formula, ...metric }) => argv.full + ? { ...metric, formula, table_recommendations: metricTableRecommendations(metric.supported_tables) } + : metric), + meta: { total: dataMetrics.length, provenance } + }, structuredFormat(argv.format)) + }) + .command('metric ', 'Return one complete dashboard metric formula and interpretation', metric => withFormatOption(metric) + .positional('name', { describe: 'Metric name returned by data metrics', type: 'string', demandOption: true }), argv => { + const found = findMetric(String(argv.name)) + if (found === undefined) throw inputError(`Unknown data metric '${String(argv.name)}'.`, { available_metrics: dataMetrics.map(metric => metric.name) }) + writeStructured({ data: { ...found, table_recommendations: metricTableRecommendations(found.supported_tables) }, meta: { provenance } }, structuredFormat(argv.format)) + }) + .command('recipes', 'List bundled analytics recipes and typed parameter requirements', recipes => withFormatOption(recipes), argv => { + writeStructured({ data: dataRecipes.map(({ query, ...recipe }) => ({ ...recipe, placeholders: recipePlaceholders(query) })), meta: { total: dataRecipes.length, date_time_zone: ANALYTICS_TIME_ZONE, provenance } }, structuredFormat(argv.format)) + }) + .command('recipe ', 'Return one bundled recipe, optionally with typed parameters filled', recipe => withRecipeParameters(recipe, false) + .positional('name', { describe: 'Recipe name returned by data recipes', type: 'string', demandOption: true }) + .option('query-only', { describe: 'Emit only the query object', type: 'boolean', default: false }), argv => { + const { recipe: found, query } = resolveRecipe(String(argv.name), argv, projectGameId) + writeStructured(argv.queryOnly + ? query + : { data: { ...found, query }, meta: { unresolved_placeholders: recipePlaceholders(query), date_time_zone: ANALYTICS_TIME_ZONE, provenance } }, structuredFormat(argv.format)) + }) + .command('freshness', 'Query the existing table_update_times table for source freshness', freshness => withDataRequestOptions(freshness), async argv => { + await executeQuery(api, { + from: 'table_update_times', + select: [{ field: 'table_name' }, { field: 'last_updated_at' }], + order: [{ field: 'last_updated_at', direction: 'asc' }], + limit: 10000 + // No recipe name: freshness is a synthesized command query, and + // meta.evidence.recipe must never name a recipe that cannot be read + // back with `poki data recipe`. + }, argv) + }) + .command('provenance', 'Return bundled analytics snapshot metadata and discovery commands', source => withFormatOption(source), argv => { + writeStructured({ data: provenance, meta: {} }, structuredFormat(argv.format)) + }) + .demandCommand(1, 'Choose data query, run, describe, tables, table, column, metrics, metric, recipes, recipe, freshness, or provenance.'), () => {}) +} diff --git a/src/commands/discovery.ts b/src/commands/discovery.ts new file mode 100644 index 0000000..9358067 --- /dev/null +++ b/src/commands/discovery.ts @@ -0,0 +1,38 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { getAuthStatus } from '../auth' +import { readProjectConfigContext } from '../project' +import { CLI_VERSION } from '../version' +import { getResource, render, withFormatOption, withOutputOptions } from './common' + +export function registerDiscoveryCommands (yargs: Argv, api: ApiClient): Argv { + return yargs + .command('context', 'Describe the effective local project, API, CLI, and authentication context without contacting the API', context => withFormatOption(context), argv => { + const project = readProjectConfigContext() + const auth = getAuthStatus() + const hints: string[] = [] + if (project.config.game_id === undefined || project.config.game_id === '') { + hints.push('No project game configured. Run `poki init --game GAME_ID` here, or pass --game to game-scoped commands.') + } + if (!auth.authenticated) { + hints.push('Not authenticated. Run `poki auth login` (opens a browser and needs a human to complete sign-in).') + } + render({ + project: { + source: project.source, + path: project.path ?? null, + game_id: project.config.game_id ?? null, + build_dir: project.config.build_dir ?? null + }, + api: { base_url: api.baseUrl, timeout_ms: api.timeoutMs }, + cli: { version: CLI_VERSION }, + auth, + offline: true, + ...(hints.length === 0 ? {} : { hints }) + }, argv) + }) + .command('whoami', 'Return the authenticated Poki for Developers user, team relationship, and exact permission identifiers', whoami => withOutputOptions(whoami), async argv => { + render(await getResource(api, '/users/@me', argv, { type: 'users' }, 'current-user read'), argv) + }) +} diff --git a/src/commands/downloads.ts b/src/commands/downloads.ts new file mode 100644 index 0000000..7957070 --- /dev/null +++ b/src/commands/downloads.ts @@ -0,0 +1,181 @@ +import { randomUUID } from 'crypto' +import { rmSync } from 'fs' +import { link, mkdir, open, rename, rm } from 'fs/promises' +import { dirname, join } from 'path' + +import { CliError, inputError, registerInterruptCleanup } from '../errors' + +async function downloadFileOperation (destination: string, operation: () => Promise): Promise { + try { + return await operation() + } catch (error) { + if (error instanceof CliError) throw error + throw inputError(`Could not write '${destination}': ${error instanceof Error ? error.message : String(error)}`, { + output: destination + }) + } +} + +// exFAT and FAT volumes, and parts of some network and container mounts, do not +// implement hard links at all. The publication below then fails for a reason +// that has nothing to do with the destination, so these errno values select the +// fallback rather than being reported as an unwritable path. EPERM is +// deliberately included even though it is ambiguous: when the real cause is +// permission, the fallback's own exclusive create fails the same way and that +// failure is the one reported. +const hardLinkUnsupported = new Set(['EPERM', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV', 'EMLINK']) + +function destinationExists (destination: string): CliError { + return inputError(`Destination '${destination}' already exists. Pass --force to replace it.`, { output: destination }) +} + +// Publishes the completed temporary file without ever replacing an existing +// destination, including one that appears concurrently. +async function publishWithoutReplacing ( + temporary: string, + destination: string, + createLink: typeof link +): Promise { + // The same-directory hard link is the portable Node primitive that both + // publishes the complete file and fails atomically when the destination + // already exists. An existsSync()+rename() pair has a TOCTOU window and + // rename replaces a concurrently created destination on POSIX. + try { + await createLink(temporary, destination) + return + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code + if (code === 'EEXIST') throw destinationExists(destination) + if (code === undefined || !hardLinkUnsupported.has(code)) throw error + } + + // Where hard links do not exist, an exclusive create is the same atomic + // test-and-set, so a destination that already exists or appears concurrently + // still loses the race and no caller's bytes are replaced. What it cannot + // reproduce is the hard link's other property: the destination is a zero-byte + // placeholder until the rename below replaces it, so a reader inside that + // window observes an empty file rather than no file. That is why this stays a + // fallback for filesystems that leave no alternative. + const placeholder = await open(destination, 'wx').catch((error: unknown) => { + if ((error as NodeJS.ErrnoException)?.code === 'EEXIST') throw destinationExists(destination) + throw error + }) + await placeholder.close() + + // A signal between the claim and the rename would otherwise leave that + // zero-byte placeholder behind, which is precisely the partial download the + // interrupt contract exists to remove. + const removePlaceholderOnInterrupt = registerInterruptCleanup(() => { rmSync(destination, { force: true }) }) + try { + await rename(temporary, destination) + } catch (error) { + // The placeholder is ours and carries no data. Leaving it would turn a + // failed transfer into what looks like a completed empty download. + try { + await rm(destination, { force: true }) + } catch { + // Preserve the rename failure, which explains why nothing was published. + } + throw error + } finally { + removePlaceholderOnInterrupt() + } +} + +// Stream into a unique file beside the destination, then rename only after the +// complete response body has been written. This keeps an existing --force +// destination intact on timeout or network failure and makes replacement +// atomic on the destination filesystem. Only filesystem failures are mapped to +// INVALID_INPUT; response-body failures must reach ApiClient so they retain the +// generic signed-download timeout/network contract. +export async function writeDownload ( + destination: string, + body: ReadableStream | null, + force = false, + // Injected only so a test can exercise the no-hard-link publication path on a + // filesystem that does support hard links. + createLink: typeof link = link +): Promise { + const directory = dirname(destination) + const temporary = join(directory, `.poki-download-${process.pid}-${randomUUID()}.tmp`) + // A signal never runs the finally below, and the temporary name is unique per + // invocation, so an interrupted retry would otherwise leave one hidden + // partial file per attempt beside the destination. + const removeTemporaryOnInterrupt = registerInterruptCleanup(() => { rmSync(temporary, { force: true }) }) + const reader = body?.getReader() + let file: Awaited> | undefined + let bodyComplete = body === null + let bytes = 0 + + try { + await downloadFileOperation(destination, async () => await mkdir(directory, { recursive: true })) + file = await downloadFileOperation(destination, async () => await open(temporary, 'wx')) + + if (reader !== undefined) { + while (true) { + // Deliberately outside downloadFileOperation: a rejected read is a + // transport failure, not evidence that the destination is invalid. + const chunk = await reader.read() + if (chunk.done) { + bodyComplete = true + break + } + + let offset = 0 + while (offset < chunk.value.byteLength) { + const result = await downloadFileOperation(destination, async () => await file?.write( + chunk.value, + offset, + chunk.value.byteLength - offset + )) + const written = result?.bytesWritten ?? 0 + if (written === 0) { + throw inputError(`Could not write '${destination}': the filesystem wrote zero bytes.`, { output: destination }) + } + offset += written + bytes += written + } + } + } + + await downloadFileOperation(destination, async () => await file?.close()) + file = undefined + + await downloadFileOperation(destination, async () => { + if (force) { + await rename(temporary, destination) + return + } + await publishWithoutReplacing(temporary, destination, createLink) + }) + return bytes + } finally { + if (!bodyComplete && reader !== undefined) { + try { + await reader.cancel() + } catch { + // Preserve the original body-read or filesystem failure. + } + } + try { + reader?.releaseLock() + } catch { + // Preserve the original failure. + } + if (file !== undefined) { + try { + await file.close() + } catch { + // Preserve the original failure. + } + } + try { + await rm(temporary, { force: true }) + } catch { + // Preserve the original result. A successful no-force publication has a + // second hard link at destination; removing this temporary name is only + // cleanup and cannot make the published file partial. + } + removeTemporaryOnInterrupt() + } +} diff --git a/src/commands/game-change-requests.ts b/src/commands/game-change-requests.ts new file mode 100644 index 0000000..759fdd7 --- /dev/null +++ b/src/commands/game-change-requests.ts @@ -0,0 +1,149 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { gameChangeRequestsDocumentation } from '../docs/resources' +import { inputError } from '../errors' +import { characterCount, containsZeroWidthCharacter, requireChanges } from '../input' +import { jsonApiDocument } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { registerResourceDiscovery } from './resource-docs' +import { + gamePath, + getFromCollection, + listResources, + mutationInputFields, + render, + renderList, + renderMutation, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withGameMutationOptions, + withListOptions, + withOutputOptions +} from './common' + +const createInput = mutationInputFields({ + title: 'title', + thumbnailFile: 'thumbnail', + customContentSecurityPolicy: 'custom_content_security_policy', + cspReason: 'custom_content_security_policy_reasons' +}) + +function reasons (values: unknown): Record { + const result: Record = {} + for (const value of Array.isArray(values) ? values.map(String) : []) { + const separator = value.indexOf('=') + const source = value.slice(0, separator).trim() + const reason = value.slice(separator + 1).trim() + if (separator <= 0 || source === '' || reason === '') throw inputError(`Invalid CSP reason '${value}'. Use source=reason.`) + if (characterCount(reason) > 200) throw inputError(`CSP reason for '${source}' must contain at most 200 characters.`) + result[source] = reason + } + return result +} + +function pathFor (game: unknown, request?: unknown): string { + return gamePath(game, 'change_requests', ...(request === undefined ? [] : [request])) +} + +interface ThumbnailInput { + value: string + preview: Record +} + +function thumbnailFromFile (path: string): ThumbnailInput { + const absolutePath = resolve(path) + try { + const contents = readFileSync(absolutePath) + const value = contents.toString('base64') + return { + value, + preview: { + encoding: 'base64', + source_file: absolutePath, + source_bytes: contents.byteLength, + encoded_characters: value.length, + value_omitted: true + } + } + } catch (error) { + throw inputError(`Could not read thumbnail file '${absolutePath}'.`, { + path: absolutePath, + cause: error instanceof Error ? error.message : String(error) + }) + } +} + +export function registerGameChangeRequestCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('game-change-requests', 'List, inspect, create, and cancel developer game change requests', requests => registerResourceDiscovery(requests, gameChangeRequestsDocumentation) + .command('list', 'List change requests for one game', list => withDefaultGameOption(withListOptions(list, listCapabilities.gameChangeRequests, 'game-change-requests'), projectGameId, 'Game whose requests to list'), async argv => { + renderList(await listResources(api, pathFor(argv.game), argv, listCapabilities.gameChangeRequests), argv, 'game-change-requests') + }) + .command('get ', 'Get one game change request by filtering the game collection', get => withDefaultGameOption(withOutputOptions(get), projectGameId, 'Game that owns the request') + .positional('request-id', { describe: 'Game change request ID', type: 'string', demandOption: true }), async argv => { + render(await getFromCollection(api, pathFor(argv.game), argv, listCapabilities.gameChangeRequests, 'id', { type: 'game_change_requests', id: String(argv.requestId) }, { + label: 'game change request', + hint: 'Run `poki game-change-requests list` to see visible request IDs.' + }), argv) + }) + .command('create', 'Request a title, thumbnail, or custom Content Security Policy change', create => withGameMutationOptions(withDataOption(create, 'JSON or TOON request fields; thumbnail must already be base64'), projectGameId, 'Game to change') + .option('title', { describe: 'Requested public title, 3 through 128 characters', type: 'string' }) + .option('thumbnail-file', { describe: 'Image file encoded to the API thumbnail field as base64', type: 'string' }) + .option('custom-content-security-policy', { describe: 'Requested CSP string; use an empty string to remove the custom CSP', type: 'string' }) + .option('csp-reason', { describe: 'CSP source and reason in source=reason form; repeatable; each reason is at most 200 characters', type: 'array', string: true }), async argv => { + // Read inside the flag branch: --data and --thumbnail-file are mutually + // exclusive, so an unreadable file must never pre-empt that rejection. + let thumbnail: ThumbnailInput | undefined + const data = await resolveMutationInput(argv, createInput, () => { + thumbnail = argv.thumbnailFile === undefined ? undefined : thumbnailFromFile(String(argv.thumbnailFile)) + return { + ...(argv.title === undefined ? {} : { title: argv.title }), + ...(thumbnail === undefined ? {} : { thumbnail: thumbnail.value }), + ...(argv.customContentSecurityPolicy === undefined ? {} : { custom_content_security_policy: argv.customContentSecurityPolicy }), + ...(argv.cspReason === undefined ? {} : { custom_content_security_policy_reasons: reasons(argv.cspReason) }) + } + }) + requireChanges(data) + if (data.title !== undefined && (typeof data.title !== 'string' || characterCount(data.title.trim()) < 3 || characterCount(data.title) > 128)) throw inputError('title must contain 3 through 128 characters.') + if (typeof data.title === 'string' && containsZeroWidthCharacter(data.title)) throw inputError('title must not contain zero-width characters.') + if (data.thumbnail !== undefined && (typeof data.thumbnail !== 'string' || data.thumbnail === '')) throw inputError('thumbnail must be a non-empty base64 string.') + if (data.custom_content_security_policy !== undefined && typeof data.custom_content_security_policy !== 'string') throw inputError('custom_content_security_policy must be a string.') + if (data.custom_content_security_policy_reasons !== undefined) { + const suppliedReasons = data.custom_content_security_policy_reasons + if (suppliedReasons === null || typeof suppliedReasons !== 'object' || Array.isArray(suppliedReasons) || Object.entries(suppliedReasons).some(([source, reason]) => source.trim() === '' || typeof reason !== 'string' || reason.trim() === '' || characterCount(reason) > 200)) { + throw inputError('custom_content_security_policy_reasons must map non-empty sources to strings of at most 200 characters.') + } + } + if (data.title === undefined && data.thumbnail === undefined && data.custom_content_security_policy === undefined) { + throw inputError('At least title, thumbnail, or custom_content_security_policy is required.') + } + const path = pathFor(argv.game) + const body = jsonApiDocument('game_change_requests', data) + const previewBody = data.thumbnail === undefined + ? body + : jsonApiDocument('game_change_requests', { + ...data, + thumbnail: thumbnail?.preview ?? { + encoding: 'base64', + encoded_characters: data.thumbnail.length, + value_omitted: true + } + }) + await renderMutation(api, argv, { method: 'POST', path, body, previewBody, expected: { type: 'game_change_requests' }, behavior: { sideEffects: ['May create an approval request or apply eligible game changes immediately.'] } }) + }) + .command('cancel ', 'Cancel your own pending game change request', cancel => withGameMutationOptions(cancel, projectGameId, 'Game that owns the request', { destructive: true }) + .positional('request-id', { describe: 'Pending request ID created by the current user', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Cancelling a game change request') + const path = pathFor(argv.game, argv.requestId) + const body = jsonApiDocument('game_change_requests', { status: 'cancelled' }, String(argv.requestId)) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'game_change_requests', id: String(argv.requestId) }, behavior: { destructive: true, sideEffects: ['Permanently cancels the pending request.'] } }) + }) + .demandCommand(1, 'Choose game-change-requests list, get, create, or cancel.'), () => {}) +} diff --git a/src/commands/game-events.ts b/src/commands/game-events.ts new file mode 100644 index 0000000..a7bb0ea --- /dev/null +++ b/src/commands/game-events.ts @@ -0,0 +1,157 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { gameEventFunnelsDocumentation, gameEventsDocumentation } from '../docs/resources' +import { inputError } from '../errors' +import { characterCount, containsZeroWidthCharacter, requireChanges } from '../input' +import { jsonApiDocument } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { registerResourceDiscovery } from './resource-docs' +import { + asStrings, + gamePath, + listResources, + MutationInputFields, + mutationInputFields, + render, + renderList, + renderMutation, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withGameActionOptions, + withGameMutationOptions, + withListOptions, + withOutputOptions, + getResource +} from './common' + +const eventFlags = ['category', 'action', 'label', 'description', 'enabled', 'includeInFunnel'] as const +const eventUpdateInput: MutationInputFields = { flags: eventFlags, fields: ['category', 'action', 'label', 'enabled', 'description', 'include_in_funnel'] } +// Create omits the two configuration fields: the server always enables a new +// event and includes it in funnels. +const eventCreateInput: MutationInputFields = { flags: eventFlags, fields: ['category', 'action', 'label', 'description'] } +const funnelInput = mutationInputFields({ title: 'title', event: 'events' }) + +function scopedPath (game: unknown, resource: 'game_events' | 'game_event_funnels', id?: unknown): string { + return gamePath(game, resource, ...(id === undefined ? [] : [id])) +} + +async function eventData (argv: Record, create: boolean): Promise> { + const data = await resolveMutationInput(argv, create ? eventCreateInput : eventUpdateInput, () => ({ + ...(argv.category === undefined ? {} : { category: argv.category }), + ...(argv.action === undefined ? {} : { action: argv.action }), + ...(argv.label === undefined ? {} : { label: argv.label }), + ...(argv.description === undefined ? {} : { description: argv.description }), + ...(argv.enabled === undefined ? {} : { enabled: argv.enabled }), + ...(argv.includeInFunnel === undefined ? {} : { include_in_funnel: argv.includeInFunnel }) + })) + requireChanges(data) + for (const field of ['category', 'action'] as const) { + if (data[field] === undefined && !create) continue + if (typeof data[field] !== 'string' || data[field].trim() === '') throw inputError(`${field} must be a non-empty string.`) + if (characterCount(data[field]) > 64 || data[field].includes('/') || data[field].includes('^')) throw inputError(`${field} must contain 1 through 64 characters and must not contain '/' or '^'.`) + if (containsZeroWidthCharacter(data[field])) throw inputError(`${field} must not contain zero-width characters.`) + } + if (data.label !== undefined && (typeof data.label !== 'string' || characterCount(data.label) > 64 || data.label.includes('/') || data.label.includes('^'))) throw inputError("label must contain at most 64 characters and must not contain '/' or '^'.") + if (typeof data.label === 'string' && containsZeroWidthCharacter(data.label)) throw inputError('label must not contain zero-width characters.') + if (data.description !== undefined && (typeof data.description !== 'string' || characterCount(data.description) > 10000)) throw inputError('description must contain at most 10000 characters.') + if (typeof data.description === 'string' && containsZeroWidthCharacter(data.description)) throw inputError('description must not contain zero-width characters.') + if (create && (typeof data.description !== 'string' || data.description.trim() === '')) throw inputError('description is required.') + for (const field of ['enabled', 'include_in_funnel'] as const) { + if (data[field] !== undefined && typeof data[field] !== 'boolean') throw inputError(`${field} must be a boolean.`) + } + return data +} + +function withEventMutation (yargs: Argv, projectGameId: string | undefined, create: boolean): Argv { + let command = withGameMutationOptions(withDataOption(yargs, create + ? 'JSON or TOON category, action, label, and description fields; the server always enables new events and includes them in funnels' + : 'JSON or TOON event fields inline, from @file, or stdin'), projectGameId, 'Game that owns the event') + .option('category', { describe: "SDK measure category; 1-64 characters and no '/' or '^'", type: 'string' }) + .option('action', { describe: "SDK measure what value (legacy API field name); 1-64 characters and no '/' or '^'", type: 'string' }) + .option('label', { describe: "SDK measure action value (legacy API field name); 0-64 characters and no '/' or '^'", type: 'string' }) + .option('description', { describe: create ? 'Required human-readable purpose' : 'Human-readable purpose', type: 'string' }) + if (!create) { + command = command + .option('enabled', { describe: 'Whether analytics should expose the event', type: 'boolean' }) + .option('include-in-funnel', { describe: 'Whether the event may be selected in funnels', type: 'boolean' }) + } + return command +} + +async function funnelData (argv: Record, create: boolean): Promise> { + const data = await resolveMutationInput(argv, funnelInput, () => ({ + ...(argv.title === undefined ? {} : { title: argv.title }), + ...(argv.event === undefined ? {} : { events: asStrings(argv.event) }) + })) + requireChanges(data) + if ((create || data.title !== undefined) && (typeof data.title !== 'string' || data.title.trim() === '' || characterCount(data.title) > 128)) throw inputError('title must contain 1 through 128 characters.') + if (typeof data.title === 'string' && containsZeroWidthCharacter(data.title)) throw inputError('title must not contain zero-width characters.') + if ((create || data.events !== undefined) && (!Array.isArray(data.events) || data.events.length < 1 || data.events.length > 50 || data.events.some(event => typeof event !== 'string' || event.trim() === ''))) { + throw inputError('events must be an array of 1 through 50 non-empty caret-delimited event keys.') + } + if (Array.isArray(data.events) && data.events.some(event => typeof event === 'string' && containsZeroWidthCharacter(event))) throw inputError('events must not contain zero-width characters.') + return data +} + +function withFunnelMutation (yargs: Argv, projectGameId: string | undefined): Argv { + return withGameMutationOptions(withDataOption(yargs, 'JSON or TOON object containing title and caret-delimited event keys'), projectGameId, 'Game that owns the funnel') + .option('title', { describe: 'Funnel title, up to 128 characters', type: 'string' }) + .option('event', { describe: "Ordered category^what^action key from funnel analytics; '^' is the separator; repeat in traversal order", type: 'array', string: true }) +} + +export function registerGameEventCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs + .command('game-events', 'List and manage custom SDK event definitions for one game', events => registerResourceDiscovery(events, gameEventsDocumentation) + .command('list', 'List custom event definitions', list => withDefaultGameOption(withListOptions(list, listCapabilities.gameEvents, 'game-events'), projectGameId, 'Game whose events to list'), async argv => { + renderList(await listResources(api, scopedPath(argv.game, 'game_events'), argv, listCapabilities.gameEvents), argv, 'game-events') + }) + .command('create', 'Create and enable a custom event definition', create => withEventMutation(create, projectGameId, true), async argv => { + const data = await eventData(argv, true) + const path = scopedPath(argv.game, 'game_events') + const body = jsonApiDocument('game_events', data) + await renderMutation(api, argv, { method: 'POST', path, body, expected: { type: 'game_events' }, behavior: { sideEffects: ['Enables a custom event definition for analytics.'] } }) + }) + .command('update ', 'Update an existing custom event definition', update => withEventMutation(update, projectGameId, false) + .positional('event-id', { describe: 'Game event definition ID', type: 'string', demandOption: true }), async argv => { + const data = await eventData(argv, false) + const path = scopedPath(argv.game, 'game_events', argv.eventId) + const body = jsonApiDocument('game_events', data, String(argv.eventId)) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'game_events', id: String(argv.eventId) }, behavior: { sideEffects: ['Changes analytics event configuration.'] } }) + }) + .demandCommand(1, 'Choose game-events list, create, or update.'), () => {}) + .command('game-event-funnels', 'List and manage ordered custom-event funnels for one game', funnels => registerResourceDiscovery(funnels, gameEventFunnelsDocumentation) + .command('list', 'List funnels', list => withDefaultGameOption(withListOptions(list, listCapabilities.gameEventFunnels, 'game-event-funnels'), projectGameId, 'Game whose funnels to list'), async argv => { + renderList(await listResources(api, scopedPath(argv.game, 'game_event_funnels'), argv, listCapabilities.gameEventFunnels), argv, 'game-event-funnels') + }) + .command('get ', 'Get one funnel', get => withDefaultGameOption(withOutputOptions(get), projectGameId, 'Game that owns the funnel') + .positional('funnel-id', { describe: 'Funnel ID', type: 'string', demandOption: true }), async argv => { + const funnelID = String(argv.funnelId) + render(await getResource(api, scopedPath(argv.game, 'game_event_funnels', funnelID), argv, { type: 'game_event_funnels', id: funnelID }, 'game-event funnel read'), argv) + }) + .command('create', 'Create an ordered custom-event funnel', create => withFunnelMutation(create, projectGameId), async argv => { + const data = await funnelData(argv, true) + const path = scopedPath(argv.game, 'game_event_funnels') + const body = jsonApiDocument('game_event_funnels', data) + await renderMutation(api, argv, { method: 'POST', path, body, expected: { type: 'game_event_funnels' }, behavior: { sideEffects: ['Creates a reusable analytics funnel.'] } }) + }) + .command('update ', 'Replace funnel title and/or ordered events', update => withFunnelMutation(update, projectGameId) + .positional('funnel-id', { describe: 'Funnel ID', type: 'string', demandOption: true }), async argv => { + const data = await funnelData(argv, false) + const path = scopedPath(argv.game, 'game_event_funnels', argv.funnelId) + const body = jsonApiDocument('game_event_funnels', data, String(argv.funnelId)) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'game_event_funnels', id: String(argv.funnelId) }, behavior: { sideEffects: ['Replaces the supplied funnel fields.'] } }) + }) + .command('delete ', 'Delete a custom-event funnel', remove => withGameActionOptions(remove, projectGameId, 'Game that owns the funnel', { destructive: true }) + .positional('funnel-id', { describe: 'Funnel ID', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Deleting a game-event funnel') + const id = String(argv.funnelId) + await renderMutation(api, argv, { method: 'DELETE', path: scopedPath(argv.game, 'game_event_funnels', id), expected: { type: 'game_event_funnels', id }, behavior: { destructive: true, sideEffects: ['Deletes the funnel from developer workflows.'] }, action: { result: { id, deleted: true } } }) + }) + .demandCommand(1, 'Choose game-event-funnels list, get, create, update, or delete.'), () => {}) +} diff --git a/src/commands/games.ts b/src/commands/games.ts new file mode 100644 index 0000000..cd57446 --- /dev/null +++ b/src/commands/games.ts @@ -0,0 +1,274 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { gamesDocumentation } from '../docs/resources' +import { CliError, inputError } from '../errors' +import { characterCount, containsZeroWidthCharacter, requireChanges } from '../input' +import { jsonApiDocument, unreadableFields, unreadableFieldsReport } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId, projectConfigError } from '../project' +import { gameReadiness } from '../readiness' +import { registerResourceDiscovery } from './resource-docs' +import { + asStrings, + ExpectedJsonApiResourceResult, + gamePath, + getResource, + listResources, + MutationInputFields, + mutationInputFields, + render, + renderList, + renderMutation, + requestTimeout, + requireExpectedJsonApiResource, + resolveMutationInput, + withDataOption, + withListOptions, + withMutationOptions, + withOutputOptions, + withRequestOptions +} from './common' + +const createInput = mutationInputFields({ + title: 'title', + team: 'team_id', + engine: 'annotations', + privacyPolicyUrl: 'privacy_policy_url', + suggestedDescription: 'suggested_description', + suggestedCategory: 'suggested_categories' +}) + +// Update accepts the same field flags - a flag still conflicts with --data on +// both commands - but cannot retitle a game or move it between teams. +const updateInput: MutationInputFields = { + flags: createInput.flags, + fields: createInput.fields.filter(field => field !== 'title' && field !== 'team_id') +} + +function validateAnnotations (value: unknown): void { + if (value === undefined) return + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw inputError('annotations must be an object containing only engine.') + const annotations = value as Record + const unsupported = Object.keys(annotations).filter(key => key !== 'engine') + if (unsupported.length > 0) { + throw inputError('Developer game mutations support only the engine annotation.', { + unsupported_annotations: unsupported, + allowed_annotations: ['engine'] + }, 'Use the dedicated game workflow for metadata that is not developer-editable.') + } + if (typeof annotations.engine !== 'string' || !/^[a-z0-9-]{2,32}$/.test(annotations.engine)) { + throw inputError('annotations.engine must contain 2 through 32 lowercase letters, digits, or hyphens.') + } +} + +function validateGameMutationData (data: Record): void { + for (const field of ['suggested_description', 'suggested_categories'] as const) { + if (data[field] !== undefined && typeof data[field] !== 'string') throw inputError(`${field} must be a string.`) + } + if (data.title !== undefined) { + if (typeof data.title !== 'string') throw inputError('title must be a string.') + const length = characterCount(data.title) + if (length < 3 || length > 128) throw inputError('title must contain 3 through 128 characters.') + if (data.title.trim() !== data.title) throw inputError('title must not have leading or trailing whitespace.') + if (containsZeroWidthCharacter(data.title)) throw inputError('title must not contain zero-width characters.') + } + + if (data.privacy_policy_url !== undefined) { + if (typeof data.privacy_policy_url !== 'string') throw inputError('privacy_policy_url must be a string.') + if (characterCount(data.privacy_policy_url) > 255) throw inputError('privacy_policy_url must contain at most 255 characters.') + if (data.privacy_policy_url !== '' && !URL.canParse(data.privacy_policy_url)) throw inputError('privacy_policy_url must be an absolute URL.') + } +} + +// Readiness reads absence as negative state, so every field it derives an +// operation status from must be readable. A field normalization dropped, or one +// hidden by a resource collapsed to its identity, is unknown: reporting it as +// `blocked` would state a condition the CLI never observed. +const readinessStateFields: Readonly> = { + games: ['team_id', 'team', 'uploader_id', 'uploader', 'tracks', 'versions', 'playtest_requests'], + users: ['team_id', 'team'], + game_versions: ['state', 'cached_latest_review_status'], + playtest_requests: ['version_id', 'version'] +} + +function readinessStateFieldsFor (type: string): readonly string[] { + return Object.prototype.hasOwnProperty.call(readinessStateFields, type) ? readinessStateFields[type] : [] +} + +function unreadableReadinessFields (resource: ExpectedJsonApiResourceResult, type: string): string[] { + const unreadable = new Set(unreadableFields(resource.raw, resource.normalized, readinessStateFieldsFor(type)) + .map(field => `${type}.${field}`)) + // Expanded versions and requests carry state too, so a dropped field on one + // of them is just as unusable as a dropped field on the game itself. + for (const entry of unreadableFieldsReport(resource.document)) { + for (const field of entry.fields) { + if (readinessStateFieldsFor(entry.type).includes(field)) unreadable.add(`${entry.type}.${field}`) + } + } + return [...unreadable] +} + +function gameFlags (argv: Record): Record { + const data: Record = {} + const mappings: Array<[string, string]> = [ + ['title', 'title'], + ['team', 'team_id'], + ['privacyPolicyUrl', 'privacy_policy_url'], + ['suggestedDescription', 'suggested_description'] + ] + for (const [flag, field] of mappings) { + if (argv[flag] !== undefined) data[field] = argv[flag] + } + + if (argv.suggestedCategory !== undefined) { + data.suggested_categories = asStrings(argv.suggestedCategory)?.join(',') + } + + if (argv.engine !== undefined) data.annotations = { engine: String(argv.engine) } + return data +} + +async function mutationData ( + argv: Record, + input: MutationInputFields +): Promise> { + const data = await resolveMutationInput(argv, input, () => gameFlags(argv)) + validateAnnotations(data.annotations) + validateGameMutationData(data) + return data +} + +function withGameFieldOptions (yargs: Argv, create: boolean): Argv { + let command = withDataOption(withMutationOptions(withOutputOptions(yargs)), 'JSON or TOON object, @file, or - for stdin; mutually exclusive with field flags') + .option('engine', { + describe: 'Developer-editable engine annotation: 2-32 lowercase letters, digits, or hyphens; the server preserves every other annotation', + type: 'string' + }) + .option('privacy-policy-url', { + describe: 'Public privacy-policy URL', + type: 'string' + }) + .option('suggested-description', { + describe: 'Developer-suggested public game description', + type: 'string' + }) + .option('suggested-category', { + describe: 'Suggested Poki content-category name; repeat for multiple names (use audiences list to discover names)', + type: 'array', + string: true + }) + + if (create) { + command = command + .option('title', { + describe: 'Game title (required without --data)', + type: 'string' + }) + .option('team', { + describe: 'Owning team ID (required without --data)', + type: 'string' + }) + } + return command +} + +export function registerGameCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + const selectedGame = (argv: Record): string => { + const positional = typeof argv.gameId === 'string' && argv.gameId !== '' ? argv.gameId : undefined + const flag = typeof argv.game === 'string' && argv.game !== '' ? argv.game : undefined + if (positional !== undefined && flag !== undefined && positional !== flag) { + throw inputError(`The positional game ID '${positional}' conflicts with --game '${flag}'.`) + } + const selected = positional ?? flag ?? projectGameId + if (selected !== undefined) return selected + const configError = projectConfigError() + if (configError !== undefined) throw configError + throw inputError('A game ID is required. Pass it positionally, pass --game, or configure game_id in poki.json or package.json.', { + accepted_inputs: ['poki games get GAME_ID', '--game GAME_ID', 'project game_id'] + }) + } + const gameFlag = (yargs: Argv): Argv => yargs.option('game', { + describe: 'Game ID; alternative to the positional game-id', + type: 'string' + }) + + return yargs.command('games', 'List, inspect, assess readiness, create, and update Poki for Developers games', games => registerResourceDiscovery(games, gamesDocumentation) + .command('list', 'List games for --team or the authenticated user\'s first team', list => withListOptions(list, listCapabilities.games, 'games') + .option('team', { + describe: 'Only return games owned by this team ID', + type: 'string' + }), async argv => { + const result = await listResources(api, '/games', argv, listCapabilities.games, [], argv.team === undefined ? [] : [['team_id', argv.team]]) + renderList(result, argv, 'games') + }) + .command('get [game-id]', 'Get one game; defaults to the configured project game', get => gameFlag(withOutputOptions(get)) + .positional('game-id', { describe: 'Poki for Developers game ID; defaults to project game_id', type: 'string' }), async argv => { + const gameID = selectedGame(argv) + render(await getResource(api, gamePath(gameID), argv, { type: 'games', id: gameID }, 'game read'), argv) + }) + .command('readiness [game-id]', 'Report visible CLI and backend readiness for version activation, Playtests, and Player Fit', readiness => gameFlag(withRequestOptions(readiness)) + .positional('game-id', { describe: 'Poki for Developers game ID; defaults to project game_id', type: 'string' }), async argv => { + const gameID = selectedGame(argv) + // Both reads are issued before either is validated: readiness documents + // that it reads /users/@me, and that must not depend on the game response. + const gameResponse = await api.request({ path: gamePath(gameID), timeoutMs: requestTimeout(argv) }) + const userResponse = await api.request({ path: '/users/@me', timeoutMs: requestTimeout(argv) }) + const gameResource = requireExpectedJsonApiResource(gameResponse.body, { type: 'games', id: gameID }, 'game readiness read') + const userResource = requireExpectedJsonApiResource(userResponse.body, { type: 'users' }, 'current-user readiness read') + const unreadable = [ + ...unreadableReadinessFields(gameResource, 'games'), + ...unreadableReadinessFields(userResource, 'users') + ] + if (unreadable.length > 0) { + throw new CliError('INVALID_API_RESPONSE', 'Readiness cannot be derived from responses whose documented state fields could not be read.', 5, { + details: { unreadable_fields: unreadable }, + retryable: false, + hint: `Inspect the responses with \`poki games get ${gameID} --raw\`. An unreadable field is never reported as a blocking condition.` + }) + } + const game = gameResource.normalized + const user = userResource.normalized + if (game.id === undefined || user.id === undefined) { + throw new CliError('INVALID_API_RESPONSE', 'Readiness requires one game resource and one current-user resource.', 5) + } + render({ + data: gameReadiness(game, user, userResource.document.meta.permissions), + meta: { + scope: 'CLI-required inputs and backend-enforced mutation conditions represented by the current game and permission responses.', + excluded: 'Dashboard-only eligibility rules are intentionally not applied.', + point_in_time: true, + backend_authoritative: true, + limitations: [ + 'The backend rechecks permissions, review history, version ownership, active requests, and version state when a mutation is sent.', + 'Hidden active Playtest requests and complete review history may not be represented in the game response.' + ], + requests: [`GET /games/${gameID}`, 'GET /users/@me'] + } + }, argv) + }) + .command('create', 'Create a game for a team', create => withGameFieldOptions(create, true), async argv => { + const data = await mutationData(argv, createInput) + if (typeof data.title !== 'string' || data.title === '') throw inputError('title is required.') + if (typeof data.team_id !== 'string' || data.team_id === '') throw inputError('team_id is required.') + + const teamID = data.team_id + const attributes = { ...data } + delete attributes.team_id + const body = jsonApiDocument('games', attributes, undefined, { + team: { type: 'teams', id: teamID } + }) + await renderMutation(api, argv, { method: 'POST', path: '/games', body, expected: { type: 'games' }, behavior: { sideEffects: ['Creates a game and may trigger stage, audit, watch, and notification workflows.'] } }) + }) + .command('update [game-id]', 'Update editable game settings; defaults to the configured project game', update => gameFlag(withGameFieldOptions(update, false)) + .positional('game-id', { describe: 'Poki for Developers game ID; defaults to project game_id', type: 'string' }), async argv => { + const data = await mutationData(argv, updateInput) + const gameID = selectedGame(argv) + requireChanges(data) + const path = gamePath(gameID) + const body = jsonApiDocument('games', data, gameID) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'games', id: gameID }, behavior: { sideEffects: ['May create audit or notification activity.'] } }) + }) + .demandCommand(1, 'Choose games list, games get, games readiness, games create, or games update.'), () => {}) +} diff --git a/src/commands/netlib-lobbies.ts b/src/commands/netlib-lobbies.ts new file mode 100644 index 0000000..73a80b3 --- /dev/null +++ b/src/commands/netlib-lobbies.ts @@ -0,0 +1,19 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { netlibLobbiesDocumentation } from '../docs/resources' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { gamePath, listResources, renderList, withDefaultGameOption, withListOptions } from './common' +import { registerResourceDiscovery } from './resource-docs' + +export function registerNetlibLobbyCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('netlib-lobbies', 'List live Netlib lobbies for one developer-owned game', lobbies => registerResourceDiscovery(lobbies, netlibLobbiesDocumentation) + .command('list', 'List live Netlib lobbies with filters, sorting, and bounded pagination', list => withDefaultGameOption(withListOptions(list, listCapabilities.netlibLobbies, 'netlib-lobbies'), projectGameId, 'Game whose Netlib lobbies to list'), async argv => { + const path = gamePath(argv.game, 'netlib', 'lobbies') + renderList(await listResources(api, path, argv, listCapabilities.netlibLobbies), argv, 'netlib-lobbies') + }) + .demandCommand(1, 'Choose netlib-lobbies list.'), () => {}) +} diff --git a/src/commands/pagination.ts b/src/commands/pagination.ts new file mode 100644 index 0000000..3382c6c --- /dev/null +++ b/src/commands/pagination.ts @@ -0,0 +1,480 @@ +import { ApiClient } from '../api' +import { CliError, inputError, notFound } from '../errors' +import { jsonValueKind, normalizeJsonApiCollection, ResourceResult, UnreadableResourceFields } from '../jsonapi' +import { isRecord } from '../json' +import type { ListCapabilities } from '../list-capabilities' +import { listSearchParams } from '../query' +import { requestTimeout } from './command-options' +import type { ExpectedJsonApiResource } from './resource-responses' + +// These ceilings protect an accidentally unbounded --all invocation. They are +// not user-visible pagination bounds: reaching one before exhaustion fails +// closed unless the caller explicitly opted into truncation with --max-*. +const allSafetyMaxPages = 100 +const allSafetyMaxItems = 10000 + +// The --all aggregation and the collection-backed singular lookup must both +// fail closed on a link cycle and on a repeated page. Their diagnostics differ +// - an incomplete list and an inconclusive lookup are different failures - but +// the bookkeeping behind those decisions is one policy and lives here so a fix +// to one scan cannot miss the other. +class PageScan { + private readonly visitedLinks = new Set() + private readonly seenPageSignatures = new Map() + + visit (url: string): void { + this.visitedLinks.add(url) + } + + visited (url: string): boolean { + return this.visitedLinks.has(url) + } + + // Returns the label of the page a repeated result set was first seen on, and + // records the page otherwise. Empty intermediate pages never form a + // signature: they do not prove exhaustion and legitimately recur. + repeatedPage (rows: unknown[], label: number): number | undefined { + if (rows.length === 0) return undefined + const signature = JSON.stringify(rows) + const firstSeen = this.seenPageSignatures.get(signature) + if (firstSeen === undefined) this.seenPageSignatures.set(signature, label) + return firstSeen + } +} +// --all keeps only the final page's normalized metadata, so the per-page +// degradation report has to be accumulated separately: dropping it would let a +// field normalization refused to represent on an earlier page read as absent +// backend state, which is exactly what the report exists to prevent. Entries +// are merged by resource identity, and a resource an explicit --max-items bound +// cut from the result is not reported: it is not in `data` to be misread. +function mergeUnreadableFields ( + accumulated: Map, + reported: unknown, + retained: unknown[] +): void { + if (!Array.isArray(reported) || reported.length === 0) return + const retainedKeys = new Set(retained.flatMap(resource => isRecord(resource) && + typeof resource.type === 'string' && + typeof resource.id === 'string' + ? [JSON.stringify([resource.type, resource.id])] + : [])) + for (const candidate of reported) { + if (!isRecord(candidate) || typeof candidate.type !== 'string' || typeof candidate.id !== 'string') continue + if (!Array.isArray(candidate.fields)) continue + const key = JSON.stringify([candidate.type, candidate.id]) + if (!retainedKeys.has(key)) continue + const entry = accumulated.get(key) ?? { type: candidate.type, id: candidate.id, fields: [] } + for (const field of candidate.fields) { + if (typeof field === 'string' && !entry.fields.includes(field)) entry.fields.push(field) + } + accumulated.set(key, entry) + } +} + +export function asStrings (value: unknown): string[] | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) return [String(value)] + return value.map(String) +} + +function assertListCapabilities (args: Record, capabilities: ListCapabilities): void { + if (!capabilities.filter && args.filter !== undefined) throw inputError('This endpoint does not support --filter.') + if (!capabilities.sort && args.sort !== undefined) throw inputError('This endpoint does not support --sort.') + if (!capabilities.pagination) { + const supplied = ['page', 'pageSize', 'all', 'maxPages', 'maxItems'].filter(name => args[name] !== undefined) + if (supplied.length > 0) { + throw inputError('This endpoint does not support pagination options.', { + unsupported_options: supplied.map(name => `--${name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`) + }) + } + } +} + +interface NextPageLink { + present: boolean + href?: string +} + +function nextPageLink (body: unknown): NextPageLink { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return { present: false } + if (!Object.prototype.hasOwnProperty.call(body, 'links')) return { present: false } + const links = (body as { links?: unknown }).links + if (links === null || typeof links !== 'object' || Array.isArray(links)) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an invalid JSON:API links member.', 5, { + details: { + expected: { links_kind: 'object' }, + received: { links_kind: jsonValueKind(links) } + } + }) + } + if (!Object.prototype.hasOwnProperty.call(links, 'next')) return { present: false } + + const next = (links as { next?: unknown }).next + if (next === null) return { present: true } + if (typeof next === 'string') return { present: true, href: next } + if (isRecord(next) && Object.prototype.hasOwnProperty.call(next, 'href') && typeof next.href === 'string') { + return { present: true, href: (next as { href: string }).href } + } + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an invalid JSON:API next-page link.', 5, { + details: { + expected: { next_kinds: ['string', 'object_with_string_href', 'null'] }, + received: { + next_kind: jsonValueKind(next), + ...(isRecord(next) + ? { + href_member: Object.prototype.hasOwnProperty.call(next, 'href') ? 'present' : 'missing', + ...(Object.prototype.hasOwnProperty.call(next, 'href') + ? { href_kind: jsonValueKind((next as { href?: unknown }).href) } + : {}) + } + : {}) + } + } + }) +} + +function resolvedNextPageLink (api: ApiClient, body: unknown, currentUrl: URL): NextPageLink { + const next = nextPageLink(body) + if (next.href === undefined) return next + + let url: URL + try { + url = new URL(next.href, currentUrl) + } catch { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an invalid JSON:API next-page URL.', 5, { + details: { + expected: { next_url: 'same_origin_http_or_https_url' }, + received: { next_url_kind: 'invalid_url' } + } + }) + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an unsupported JSON:API next-page URL.', 5, { + details: { + expected: { next_url: 'same_origin_http_or_https_url' }, + received: { next_url_kind: 'unsupported_protocol' } + } + }) + } + if (!api.isApiOrigin(url)) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned a JSON:API next-page URL for another origin.', 5, { + details: { + expected: { next_url: 'same_origin_http_or_https_url' }, + received: { next_url_kind: 'different_origin' } + } + }) + } + return { present: true, href: url.toString() } +} +export async function listResources ( + api: ApiClient, + path: string, + args: Record, + capabilities: ListCapabilities, + extraFilters: Array<[string, string]> = [], + directParams: Array<[string, string]> = [], + // The collection-backed singular lookup needs the untouched page a resource + // was found on to answer --raw; aggregated normalized data cannot supply it. + // Returning true means that lookup is complete, so stop before validating or + // following a continuation the caller no longer needs. + onPage?: (body: unknown, resources: unknown[]) => boolean +): Promise { + assertListCapabilities(args, capabilities) + if (!capabilities.pagination) { + const query = listSearchParams({ + filter: capabilities.filter ? asStrings(args.filter) : undefined, + sort: capabilities.sort ? asStrings(args.sort) : undefined + }, extraFilters) + for (const [name, value] of directParams) query.append(name, value) + const response = await api.request({ path, query, timeoutMs: requestTimeout(args) }) + return args.raw === true ? response.body : normalizeJsonApiCollection(response.body) + } + + const pageSize = args.pageSize === undefined ? 30 : Number(args.pageSize) + let page = args.all === true ? 1 : args.page === undefined ? 1 : Number(args.page) + const resources: unknown[] = [] + let lastMeta: Record = {} + let linkedRequest: string | undefined + const scan = new PageScan() + const explicitMaxPages = args.maxPages !== undefined + const explicitMaxItems = args.maxItems !== undefined + const maxPages = explicitMaxPages ? Number(args.maxPages) : allSafetyMaxPages + const maxItems = explicitMaxItems ? Number(args.maxItems) : allSafetyMaxItems + let pagesFetched = 0 + let truncated = false + let nextLink: string | undefined + let lastNext: NextPageLink = { present: false } + let lastPageLength = 0 + const unreadable = new Map() + + do { + const requestWasLinked = linkedRequest !== undefined + let query: URLSearchParams | undefined + if (!requestWasLinked) { + query = listSearchParams({ + filter: capabilities.filter ? asStrings(args.filter) : undefined, + sort: capabilities.sort ? asStrings(args.sort) : undefined, + page, + pageSize + }, extraFilters) + for (const [name, value] of directParams) query.append(name, value) + } + const response = await api.request({ path: linkedRequest ?? path, query, timeoutMs: requestTimeout(args) }) + pagesFetched++ + if (args.raw === true) return response.body + + const normalized = normalizeJsonApiCollection(response.body, page, pageSize) + const pageData = normalized.data === null ? [] : normalized.data as unknown[] + if (onPage?.(response.body, pageData) === true) return normalized + const currentUrl = api.resolveApiUrl(linkedRequest ?? path, query) + // Validate and resolve an authoritative continuation before it can be + // followed or reflected in normalized pagination metadata. + const next = resolvedNextPageLink(api, response.body, currentUrl) + lastNext = next + if (args.all === true && next.present && next.href !== undefined) { + scan.visit(currentUrl.toString()) + if (next.href === currentUrl.toString() || scan.visited(next.href)) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned a cyclic JSON:API next-page link.', 5, { + details: { + cycle: next.href === currentUrl.toString() ? 'self' : 'previously_visited', + pages_fetched: pagesFetched + } + }) + } + scan.visit(next.href) + } + nextLink = next.href + const firstSeenPage = args.all === true ? scan.repeatedPage(pageData, page) : undefined + if (firstSeenPage !== undefined) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API repeated a result page while fetching --all; completeness cannot be established.', 5, { + details: { page, page_size: pageSize, pages_fetched: pagesFetched, first_seen_page: firstSeenPage, pagination: requestWasLinked ? 'link' : 'numeric' }, + retryable: false, + hint: 'Retry a bounded single page or report that this endpoint may be repeating pagination results.' + }) + } + const remaining = maxItems - resources.length + const retained = args.all === true ? pageData.slice(0, remaining) : pageData + resources.push(...retained) + mergeUnreadableFields(unreadable, normalized.meta.unreadable_fields, retained) + lastMeta = normalized.meta + lastPageLength = pageData.length + + if (args.all === true && resources.length >= maxItems) { + const stoppedInsidePage = pageData.length > remaining + const hasMore = stoppedInsidePage || (next.present ? next.href !== undefined : pageData.length >= pageSize) + // A server next link resumes after the complete current page. It is not + // a safe continuation cursor when --max-items stopped inside that page. + if (stoppedInsidePage) nextLink = undefined + if (hasMore && !explicitMaxItems) { + throw new CliError('INVALID_API_RESPONSE', `Fetching --all reached the internal ${String(allSafetyMaxItems)}-resource safety ceiling before exhaustion; completeness cannot be established.`, 5, { + details: { + safety_ceiling: { max_items: allSafetyMaxItems }, + fetched: resources.length, + pages_fetched: pagesFetched + }, + retryable: false, + hint: 'Supply an explicit --max-items or --max-pages only when a bounded, possibly incomplete result is acceptable.' + }) + } + truncated = hasMore + break + } + + if (args.all !== true) break + + if (next.present) { + if (next.href === undefined) break + linkedRequest = next.href + } else if (requestWasLinked || pageData.length < pageSize) { + break + } + + if (pagesFetched >= maxPages) { + if (!explicitMaxPages) { + throw new CliError('INVALID_API_RESPONSE', `Fetching --all reached the internal ${String(allSafetyMaxPages)}-page safety ceiling before exhaustion; completeness cannot be established.`, 5, { + details: { + safety_ceiling: { max_pages: allSafetyMaxPages }, + fetched: resources.length, + pages_fetched: pagesFetched, + ...(explicitMaxItems ? { bounds: { max_items: maxItems } } : {}) + }, + retryable: false, + // An explicit --max-items is an accepted bound but cannot lift the + // internal page ceiling, so telling that caller to supply --max-items + // would repeat what they already did. + hint: explicitMaxItems + ? `The internal ${String(allSafetyMaxPages)}-page ceiling stopped this scan before --max-items was reached; no link cycle was detected. Lower --max-items or add an explicit --max-pages when a bounded, possibly incomplete result is acceptable.` + : 'Supply an explicit --max-pages or --max-items only when a bounded, possibly incomplete result is acceptable.' + }) + } + truncated = true + break + } + page++ + } while (true) + + // Endpoints report per-page totals inconsistently, so an aggregated --all + // response drops the server total; fetched is the count actually collected. + const aggregatedMeta = { ...lastMeta } + delete aggregatedMeta.total + // The last page's own report is replaced by the one accumulated across every + // page, so a degradation on an earlier page survives the aggregation. + delete aggregatedMeta.unreadable_fields + return { + data: resources, + meta: args.all === true + ? { + ...aggregatedMeta, + ...(unreadable.size === 0 ? {} : { unreadable_fields: [...unreadable.values()] }), + fetched: resources.length, + page: 1, + page_size: resources.length, + pages_fetched: pagesFetched, + truncated, + has_next: truncated, + ...(explicitMaxPages || explicitMaxItems + ? { + bounds: { + ...(explicitMaxPages ? { max_pages: maxPages } : {}), + ...(explicitMaxItems ? { max_items: maxItems } : {}) + } + } + : {}), + ...(truncated && nextLink !== undefined ? { next: nextLink } : {}) + } + : { + ...lastMeta, + // The Poki API does not emit a JSON:API links member, so an + // authoritative continuation is normally absent. has_next must stay + // present anyway: an absent signal is indistinguishable from proven + // completeness, and --format csv carries rows alone, so a missing + // has_next silently exports a truncated first page with exit 0. + // A page that filled the requested size is the same "more may exist" + // fact --all already terminates its numeric scan on. + has_next: lastNext.present ? lastNext.href !== undefined : lastPageLength >= pageSize + } + } satisfies ResourceResult +} + +export interface CollectionMatch { + resource: Record + // The untouched backend page the resource was found on. --raw returns this + // document, so both views resolve the same resource from the same scan. + document: unknown +} + +// Emulates a singular GET for collections without one. When the filtered +// request returns rows that don't match the requested identity, the server ignored +// the filter, so a bounded full scan runs before concluding the resource is +// absent — otherwise a resource beyond page one would produce a false 404. +export async function findInCollection ( + api: ApiClient, + path: string, + args: Record, + capabilities: ListCapabilities, + filterKey: string, + expected: Required +): Promise { + const query = listSearchParams({}, [[filterKey, expected.id]]) + const response = await api.request({ path, query, timeoutMs: requestTimeout(args) }) + const matches = (item: unknown): item is Record => isRecord(item) && + item.type === expected.type && + item.id === expected.id + const normalized = normalizeJsonApiCollection(response.body) + const rows = normalized.data === null ? [] : normalized.data as unknown[] + const found = rows.find(matches) + // A page that already answers the lookup is never rejected for an unusable + // continuation: links.next is validated only when another page is needed. + if (found !== undefined) return { resource: found, document: response.body } + if (!capabilities.pagination) return undefined + const firstUrl = api.resolveApiUrl(path, query) + let next = resolvedNextPageLink(api, response.body, firstUrl) + + // A filtered collection can legitimately start with an empty page and an + // authoritative continuation. Follow that chain before concluding that the + // requested resource is absent. + if (next.href !== undefined) { + const scan = new PageScan() + scan.visit(firstUrl.toString()) + scan.repeatedPage(rows, 1) + let pagesFetched = 1 + let itemsScanned = rows.length + + while (next.href !== undefined) { + if (scan.visited(next.href)) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned a cyclic JSON:API next-page link while locating a resource.', 5, { + details: { cycle: next.href === firstUrl.toString() ? 'self' : 'previously_visited', pages_fetched: pagesFetched } + }) + } + if (pagesFetched >= allSafetyMaxPages) { + throw new CliError('INVALID_API_RESPONSE', `Locating the resource reached the internal ${String(allSafetyMaxPages)}-page safety ceiling before exhaustion; completeness cannot be established.`, 5, { + details: { safety_ceiling: { max_pages: allSafetyMaxPages }, pages_fetched: pagesFetched, items_scanned: itemsScanned }, + retryable: false, + hint: 'Use the collection list command with an explicit bound and report that the singular lookup could not establish completeness.' + }) + } + + const linkedUrl = next.href + scan.visit(linkedUrl) + const linkedResponse = await api.request({ path: linkedUrl, timeoutMs: requestTimeout(args) }) + pagesFetched++ + const linkedNormalized = normalizeJsonApiCollection(linkedResponse.body) + const linkedRows = linkedNormalized.data === null ? [] : linkedNormalized.data as unknown[] + const linkedFound = linkedRows.find(matches) + if (linkedFound !== undefined) return { resource: linkedFound, document: linkedResponse.body } + + const firstSeenPage = scan.repeatedPage(linkedRows, pagesFetched) + if (firstSeenPage !== undefined) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API repeated a result page while locating a resource; completeness cannot be established.', 5, { + details: { pages_fetched: pagesFetched, first_seen_page: firstSeenPage, pagination: 'link' }, + retryable: false + }) + } + + itemsScanned += linkedRows.length + next = resolvedNextPageLink(api, linkedResponse.body, new URL(linkedUrl)) + // The ceiling only ends an unfinished scan. A chain that terminated + // without the resource was exhausted, however many rows it carried, and + // that answer is NOT_FOUND rather than an inconclusive lookup. + if (next.href !== undefined && itemsScanned >= allSafetyMaxItems) { + throw new CliError('INVALID_API_RESPONSE', `Locating the resource reached the internal ${String(allSafetyMaxItems)}-resource safety ceiling before exhaustion; completeness cannot be established.`, 5, { + details: { safety_ceiling: { max_items: allSafetyMaxItems }, pages_fetched: pagesFetched, items_scanned: itemsScanned }, + retryable: false, + hint: 'Use the collection list command with an explicit bound and report that the singular lookup could not establish completeness.' + }) + } + } + return undefined + } + + if (rows.length === 0 || next.present) return undefined + let match: CollectionMatch | undefined + await listResources(api, path, { all: true, timeoutMs: args.timeoutMs }, capabilities, [], [], (body, pageRows) => { + const resource = pageRows.find(matches) + if (resource === undefined) return false + match = { resource, document: body } + return true + }) + return match +} + +// Resources without a singular GET route are read through their collection. +// Both views resolve the resource through that one scan, so --raw cannot +// report a successful empty page for a resource the normalized read finds. +export async function getFromCollection ( + api: ApiClient, + path: string, + args: Record, + capabilities: ListCapabilities, + filterKey: string, + expected: Required, + missing: { label: string, hint: string } +): Promise { + const match = await findInCollection(api, path, args, capabilities, filterKey, expected) + if (match === undefined) throw notFound(missing.label, expected.id, missing.hint) + // --raw bypasses developer-surface filtering, not resource resolution, so it + // returns the untouched backend page that carried the requested resource. + if (args.raw === true) return match.document + return { data: match.resource, meta: {} } +} diff --git a/src/commands/paths.ts b/src/commands/paths.ts new file mode 100644 index 0000000..5e09b82 --- /dev/null +++ b/src/commands/paths.ts @@ -0,0 +1,8 @@ +// Every game-scoped route starts with the same encoded game segment, and the +// identifiers interpolated after it are user-supplied. Encoding each segment +// exactly once here means no route can be built with an unencoded identifier. +// Literal route text that must survive verbatim - an `@action` suffix, whose +// `@` encodeURIComponent would escape - is appended by the caller instead. +export function gamePath (game: unknown, ...segments: unknown[]): string { + return `/games/${[game, ...segments].map(segment => encodeURIComponent(String(segment))).join('/')}` +} diff --git a/src/commands/player-feedback-questions.ts b/src/commands/player-feedback-questions.ts new file mode 100644 index 0000000..65eb475 --- /dev/null +++ b/src/commands/player-feedback-questions.ts @@ -0,0 +1,186 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { playerFeedbackQuestionsDocumentation } from '../docs/resources' +import { CliError, inputError } from '../errors' +import { characterCount, containsZeroWidthCharacter } from '../input' +import { jsonApiDocument } from '../jsonapi' +import { isRecord } from '../json' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { AsyncCreateContract, createThenWait, pollArguments } from './async-create' +import { registerResourceDiscovery } from './resource-docs' +import { + asStrings, + gamePath, + getResource, + listResources, + mutationInputFields, + mutationPreview, + normalizeMutationResponse, + pollUntil, + PollOutcome, + render, + renderList, + renderMutation, + requestTimeout, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withGameActionOptions, + withGameMutationOptions, + withListOptions, + withOutputOptions, + withWaitMeta, + withWaitOptions +} from './common' + +const messageTypes = ['thumbs_up', 'thumbs_down', 'bugreport'] as const +const createInput = mutationInputFields({ + question: 'question', + startDate: 'start_date', + endDate: 'end_date', + messageType: 'feedback_message_types' +}) + +function questionPath (game: unknown, question?: unknown): string { + return gamePath(game, 'player_feedback_questions', ...(question === undefined ? [] : [question])) +} + +// Generation status values documented on the resource; completed and failed +// are terminal. +function questionStatusOf (resource: unknown): string { + const data = isRecord(resource) ? resource.data : undefined + const status = isRecord(data) ? data.status : undefined + return typeof status === 'string' ? status : 'unknown' +} + +async function waitForQuestion (api: ApiClient, game: unknown, questionId: string, argv: Record): Promise { + return await pollUntil(argv, async timeoutMs => { + const resource = await getResource( + api, + questionPath(game, questionId), + { ...argv, raw: false, timeoutMs }, + { type: 'player_feedback_questions', id: questionId }, + 'player-feedback question poll' + ) + const status = questionStatusOf(resource) + return { resource, state: status, terminal: status === 'completed' || status === 'failed', succeeded: status === 'completed' } + }, `player feedback question ${questionId} generation`, requestTimeout(argv) ?? api.timeoutMs) +} + +const createWaitContract: AsyncCreateContract = { + errorCode: 'PLAYER_FEEDBACK_QUESTION_CREATE_WAIT_FAILED', + noun: 'question', + missingId: { + message: 'The player-feedback question creation succeeded, but its response did not include a usable question ID.', + hint: 'Do not create the question again. Use details.recovery.inspect_created_question to list existing questions and identify the created resource.' + }, + pollFailed: { + message: 'The player-feedback question was created, but polling its generation state failed.', + hint: 'Do not create the question again. Use details.recovery.resume_poll to continue polling the created question.' + }, + inspect: argv => ({ + action: 'list_existing_player_feedback_questions', + arguments: [ + 'player-feedback-questions', 'list', + '--game', String(argv.game), + '--sort', '-created_at', + '--fields', 'id,question,status,created_at', + '--format', 'json' + ] + }), + resumePoll: (createdId, argv) => ({ + action: 'poll_existing_player_feedback_question', + arguments: [ + 'player-feedback-questions', 'get', createdId, + '--game', String(argv.game), + '--wait', + ...pollArguments(argv), + '--format', 'json' + ] + }) +} + +function unixDate (value: unknown, field: string): number { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw inputError(`${field} must be a UTC calendar date in YYYY-MM-DD format or an integer Unix timestamp.`) + } + const milliseconds = Date.parse(`${value}T00:00:00Z`) + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString().slice(0, 10) !== value) { + throw inputError(`${field} must be a real UTC calendar date in YYYY-MM-DD format.`) + } + return Math.floor(milliseconds / 1000) +} + +export function registerPlayerFeedbackQuestionCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('player-feedback-questions', 'List, inspect, create, and delete generated player-feedback questions', questions => registerResourceDiscovery(questions, playerFeedbackQuestionsDocumentation) + .command('list', 'List generated feedback questions for one game', list => withDefaultGameOption(withListOptions(list, listCapabilities.playerFeedbackQuestions, 'player-feedback-questions'), projectGameId, 'Game whose questions to list'), async argv => { + renderList(await listResources(api, questionPath(argv.game), argv, listCapabilities.playerFeedbackQuestions), argv, 'player-feedback-questions') + }) + .command('get ', 'Get one generated feedback question and response', get => withWaitOptions(withDefaultGameOption(withOutputOptions(get), projectGameId, 'Game that owns the question'), 'Poll until status reaches completed; failed exits nonzero with the final resource') + .positional('question-id', { describe: 'Player feedback question ID', type: 'string', demandOption: true }), async argv => { + if (argv.wait === true) { + const outcome = await waitForQuestion(api, argv.game, String(argv.questionId), argv) + render(withWaitMeta(outcome), argv) + return + } + const questionID = String(argv.questionId) + render(await getResource(api, questionPath(argv.game, questionID), argv, { type: 'player_feedback_questions', id: questionID }, 'player-feedback question read'), argv) + }) + .command('create', 'Queue a question over a bounded player-feedback date range', create => withWaitOptions(withGameMutationOptions(withDataOption(create, 'JSON or TOON object containing question, start_date, end_date, and feedback_message_types'), projectGameId, 'Game whose feedback to analyze'), 'After creation, poll until status reaches completed; failed exits nonzero with the final resource') + .option('question', { describe: 'Required natural-language question, up to 10000 characters', type: 'string' }) + .option('start-date', { describe: 'Required inclusive UTC date in YYYY-MM-DD format; sent as Unix seconds', type: 'string' }) + .option('end-date', { describe: 'Required inclusive UTC date in YYYY-MM-DD format; sent as Unix seconds and must not precede start-date', type: 'string' }) + .option('message-type', { describe: 'Feedback type; repeat one or more times', choices: messageTypes, type: 'array' }), async argv => { + const data = await resolveMutationInput(argv, createInput, () => ({ + question: argv.question, + start_date: argv.startDate, + end_date: argv.endDate, + feedback_message_types: asStrings(argv.messageType) + })) + if (typeof data.question !== 'string' || data.question.trim() === '' || characterCount(data.question) > 10000) throw inputError('question must contain 1 through 10000 characters.') + if (containsZeroWidthCharacter(data.question)) throw inputError('question must not contain zero-width characters.') + const startDate = unixDate(data.start_date, 'start_date') + const endDate = unixDate(data.end_date, 'end_date') + if (endDate < startDate) throw inputError('end_date must be on or after start_date.') + data.start_date = startDate + data.end_date = endDate + if (!Array.isArray(data.feedback_message_types) || data.feedback_message_types.length === 0 || data.feedback_message_types.some(type => !messageTypes.includes(type as typeof messageTypes[number]))) { + throw inputError(`feedback_message_types must contain one or more of: ${messageTypes.join(', ')}.`) + } + const path = questionPath(argv.game) + const body = jsonApiDocument('player_feedback_questions', data) + if (mutationPreview('POST', path, body, argv, { sideEffects: ['Queues asynchronous feedback analysis and model generation.'] })) return + await createThenWait({ + contract: createWaitContract, + argv, + send: async () => await api.request({ method: 'POST', path, body, timeoutMs: requestTimeout(argv) }), + normalize: (response, onRecoverySnapshot) => normalizeMutationResponse(response.body, response.status, 'POST', path, { type: 'player_feedback_questions' }, onRecoverySnapshot), + createdIdOf: (normalized, response) => { + const created = normalized.data + if (isRecord(created) && typeof created.id === 'string') return created.id + throw new CliError('INVALID_API_RESPONSE', 'The successful create response did not include a question ID.', 5, { + status: response.status, + retryable: false, + hint: 'The mutation may already have committed. Inspect current question state and do not replay the create blindly.' + }) + }, + requireCreatedId: 'when_waiting', + recoveryFromSnapshot: data => isRecord(data) && data.type === 'player_feedback_questions' ? data : undefined, + recoveryFromNormalized: data => isRecord(data) ? data : undefined, + poll: async createdId => await waitForQuestion(api, argv.game, createdId, argv) + }) + }) + .command('delete ', 'Delete a generated feedback question', remove => withGameActionOptions(remove, projectGameId, 'Game that owns the question', { destructive: true }) + .positional('question-id', { describe: 'Player feedback question ID', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Deleting a player feedback question') + const id = String(argv.questionId) + await renderMutation(api, argv, { method: 'DELETE', path: questionPath(argv.game, id), expected: { type: 'player_feedback_questions', id }, behavior: { destructive: true, sideEffects: ['Deletes the generated question and response resource.'] }, action: { result: { id, deleted: true } } }) + }) + .demandCommand(1, 'Choose player-feedback-questions list, get, create, or delete.'), () => {}) +} diff --git a/src/commands/player-fit-tests.ts b/src/commands/player-fit-tests.ts new file mode 100644 index 0000000..791911e --- /dev/null +++ b/src/commands/player-fit-tests.ts @@ -0,0 +1,110 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { playerFitTestsDocumentation } from '../docs/resources' +import { inputError } from '../errors' +import { jsonApiDocument } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { + applyAudienceInputDefaults, + audienceInputFromFlags, + audienceOrientations, + CategoryLimit, + deviceCategories, + validateAudienceInput +} from './audience-input' +import { registerResourceDiscovery } from './resource-docs' +import { + asStrings, + gamePath, + getFromCollection, + listResources, + mutationInputFields, + render, + renderList, + renderMutation, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withGameMutationOptions, + withListOptions, + withOutputOptions +} from './common' + +const createInput = mutationInputFields({ + deviceCategory: 'device_category', + category: 'categories', + categoryOnly: 'category_only', + orientation: 'orientation', + country: 'countries' +}) +const categoryLimit: CategoryLimit = { max: 5, message: 'Player Fit tests support at most five categories.' } + +function countriesValue (value: unknown): string { + const values = asStrings(value) ?? [] + if (values.some(country => !/^[A-Z]{2}$/.test(country))) { + throw inputError('--country values must be uppercase two-letter country codes.') + } + return values.join(',') +} + +function validateCreateData (data: Record): void { + validateAudienceInput(data, { categoryLimit }) + if (typeof data.countries !== 'string') throw inputError('countries must be a comma-separated list of uppercase two-letter country codes.') + const countries = data.countries + if (countries !== '' && !/^[A-Z]{2}(,[A-Z]{2})*$/.test(countries)) { + throw inputError('countries must be a comma-separated list of uppercase two-letter country codes.') + } + if (typeof data.category_only !== 'boolean') throw inputError('category_only must be a boolean.') +} + +export function registerPlayerFitTestCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('player-fit-tests', 'List, inspect, and create Player Fit tests', tests => registerResourceDiscovery(tests, playerFitTestsDocumentation) + .command('list', 'List Player Fit tests for a game', list => withDefaultGameOption(withListOptions(list, listCapabilities.playerFitTests, 'player-fit-tests'), projectGameId, 'Use the developer-accessible tests for this game'), async argv => { + renderList(await listResources(api, gamePath(argv.game, 'player_fit_tests'), argv, listCapabilities.playerFitTests), argv, 'player-fit-tests') + }) + .command('get ', 'Get one Player Fit test from its game-scoped collection', get => withDefaultGameOption(withOutputOptions(get), projectGameId, 'Game ID that owns the test') + .positional('test-id', { describe: 'Player Fit test ID', type: 'string', demandOption: true }), async argv => { + render(await getFromCollection(api, gamePath(argv.game, 'player_fit_tests'), argv, listCapabilities.playerFitTests, 'id', { type: 'player_fit_tests', id: String(argv.testId) }, { + label: 'Player Fit test', + hint: 'Run `poki player-fit-tests list` to see visible test IDs.' + }), argv) + }) + .command('create', 'Create a Player Fit test with the product-defined target of 500 gameplays', create => withGameMutationOptions(withDataOption(create, 'JSON or TOON audience-settings object, @file, or - for stdin; version remains a flag and game may come from project configuration'), projectGameId, 'Game ID that owns the version') + .option('version', { describe: 'Version ID to test', type: 'string', demandOption: true }) + .option('device-category', { describe: 'Device audience', choices: deviceCategories }) + .option('orientation', { describe: 'Required screen orientation', choices: audienceOrientations }) + .option('category', { describe: 'Numeric category ID; repeat up to five times', type: 'array', string: true }) + .option('category-only', { describe: 'Restrict recruitment to the selected categories', type: 'boolean' }) + .option('country', { describe: 'Uppercase two-letter country code; repeat for multiple countries', type: 'array', string: true }), async argv => { + const data = await resolveMutationInput(argv, createInput, () => ({ + ...audienceInputFromFlags(argv, { defaults: true, categoryLimit }), + category_only: argv.categoryOnly ?? false, + countries: countriesValue(argv.country) + })) + applyAudienceInputDefaults(data) + data.category_only ??= false + data.countries ??= '' + validateCreateData(data) + + const attributes = { + ...data, + game_id: argv.game, + version_id: argv.version, + target_gameplays: 500 + } + const body = jsonApiDocument('player_fit_tests', attributes) + await renderMutation(api, argv, { method: 'POST', path: gamePath(argv.game, 'player_fit_tests'), body, expected: { type: 'player_fit_tests' }, behavior: { sideEffects: ['Starts recruitment and may advance the self-service stage or notify watchers.'] } }) + }) + .command('stop ', 'Stop an active Player Fit test', stop => withGameMutationOptions(stop, projectGameId, 'Game ID that owns the test', { destructive: true }) + .positional('test-id', { describe: 'Player Fit test ID', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Stopping a Player Fit test') + const id = String(argv.testId) + await renderMutation(api, argv, { method: 'POST', path: `${gamePath(argv.game, 'player_fit_tests', id)}/@stop`, expected: { type: 'player_fit_tests', id }, behavior: { destructive: true, sideEffects: ['Permanently stops recruitment for this test.'] }, action: { result: { id, stopped: true } } }) + }) + .demandCommand(1, 'Choose player-fit-tests list, get, create, or stop.'), () => {}) +} diff --git a/src/commands/playtest-requests.ts b/src/commands/playtest-requests.ts new file mode 100644 index 0000000..becf20e --- /dev/null +++ b/src/commands/playtest-requests.ts @@ -0,0 +1,344 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { playtestRequestsDocumentation } from '../docs/resources' +import { CliError, inputError, notFound, safeErrorCause } from '../errors' +import { requireChanges } from '../input' +import { jsonApiDocument, unreadableFields, unreadableFieldsReport } from '../jsonapi' +import { isRecord } from '../json' +import { writeStructured } from '../output' +import { getProjectGameId } from '../project' +import { audienceInputFromFlags } from './audience-input' +import { registerResourceDiscovery } from './resource-docs' +import { + gamePath, + normalizeMutationResponse, + readExpectedResource, + render, + renderList, + renderMutation, + requestTimeout, + requireConfirmation, + requireExpectedJsonApiResource, + resolveMutationInput, + withDefaultGameOption, + withGameMutationOptions, + withListViewOptions +} from './common' +import { createPlaytestRequest, playtestRequestInput, validatePlaytestRequestData, withPlaytestRequestOptions } from './playtests' + +function editFlags (argv: Record): Record { + const data: Record = audienceInputFromFlags(argv) + if (argv.recordings !== undefined) data.recordings = argv.recordings + if (argv.newUsersOnly !== undefined) data.new_users_only = argv.newUsersOnly + if (argv.normalTile !== undefined) data.normal_tile = argv.normalTile + return data +} + +function asObjectArray (value: unknown): Array> { + return Array.isArray(value) ? value.filter(isRecord) : [] +} + +type ReplacementCreationState = 'not_created' | 'unknown' + +function replacementCreationState (error: unknown): ReplacementCreationState { + if (!(error instanceof CliError) || error.status === undefined) return 'unknown' + return error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429 + ? 'not_created' + : 'unknown' +} + +function replacementRecovery ( + gameID: string, + versionID: string, + data: Record, + state: ReplacementCreationState +): Record { + const inspectArguments = ['playtest-requests', 'list', '--game', gameID, '--format', 'json'] + const createArguments = [ + 'playtest-requests', 'create', + '--game', gameID, + '--version', versionID, + '--data', JSON.stringify(data), + '--format', 'json' + ] + return { + inspect_current_state: { + required_before_create: state === 'unknown', + command: 'poki', + arguments: inspectArguments + }, + create_replacement: { + condition: state === 'unknown' + ? 'only_after_inspection_confirms_no_active_replacement' + : 'after_correcting_the_rejection_cause', + command: 'poki', + arguments: createArguments + }, + retry_payload: { + game: gameID, + version: versionID, + data + } + } +} + +function cancellationRecovery ( + gameID: string, + requestID: string, + versionID: string, + data: Record +): Record { + const inspectArguments = ['playtest-requests', 'list', '--game', gameID, '--format', 'json'] + const retryReplaceArguments = [ + 'playtest-requests', 'replace', requestID, + '--game', gameID, + '--version', versionID, + '--data', JSON.stringify(data), + '--yes', + '--format', 'json' + ] + const createArguments = [ + 'playtest-requests', 'create', + '--game', gameID, + '--version', versionID, + '--data', JSON.stringify(data), + '--format', 'json' + ] + return { + inspect_current_state: { + required_before_next_mutation: true, + command: 'poki', + arguments: inspectArguments + }, + retry_replacement: { + condition: 'only_if_inspection_confirms_the_original_request_is_still_active', + command: 'poki', + arguments: retryReplaceArguments + }, + create_replacement: { + condition: 'only_if_inspection_confirms_the_original_request_is_cancelled_and_no_active_replacement_exists', + command: 'poki', + arguments: createArguments + } + } +} + +export function registerPlaytestRequestCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('playtest-requests', 'List, cancel, or replace active playtest requests', requests => registerResourceDiscovery(requests, playtestRequestsDocumentation) + .command('list', 'List role-visible requests for a game', list => withDefaultGameOption(withListViewOptions(list, 'playtest-requests'), projectGameId, 'Read active requests for this game'), async argv => { + const response = await api.request({ path: gamePath(argv.game), timeoutMs: requestTimeout(argv) }) + if (argv.raw === true) { + render(response.body, argv) + return + } + const gameID = String(argv.game) + const game = requireExpectedJsonApiResource(response.body, { type: 'games', id: gameID }, 'playtest-request list game read') + // An empty list is a factual claim about the backend. Normalization drops + // a member it cannot represent, so only a member the response never sent + // may be reported as "no active requests". + const unreadable = unreadableFields(game.raw, game.normalized, ['playtest_requests']) + if (unreadable.length > 0) { + throw new CliError('INVALID_API_RESPONSE', 'The playtest-request list game read did not contain a readable playtest_requests member.', 5, { + details: { unreadable_fields: unreadable }, + retryable: false, + hint: `Inspect the game response with \`poki games get ${gameID} --raw\`. An unreadable member is never reported as an empty list.` + }) + } + const data = (game.normalized as { playtest_requests?: unknown[] }).playtest_requests ?? [] + const degraded = unreadableFieldsReport(game.document) + renderList({ + data, + meta: { total: data.length, ...(degraded.length === 0 ? {} : { unreadable_fields: degraded }) } + }, argv, 'playtest-requests') + }) + .command('create', 'Create a playtest request for a game version', create => withPlaytestRequestOptions(create, projectGameId), async argv => { + const result = await createPlaytestRequest(api, argv) + if (argv.dryRun !== true) render(result, argv) + }) + .command('cancel ', 'Irreversibly cancel an active playtest request', cancel => withGameMutationOptions(cancel, projectGameId, 'Game ID that owns the request', { destructive: true }) + .positional('request-id', { describe: 'Playtest request ID', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Cancelling a playtest request') + const id = String(argv.requestId) + await renderMutation(api, argv, { method: 'DELETE', path: gamePath(argv.game, 'playtest-requests', id), expected: { type: 'playtest_requests', id }, behavior: { destructive: true, sideEffects: ['Cancels outstanding work; the backend exposes no restore operation.'] }, action: { result: { id, cancelled: true } } }) + }) + .command('replace ', 'Cancel an active request and create a replacement; this operation is not atomic', edit => withPlaytestRequestOptions(edit, projectGameId, false, { nonAtomic: true }) + .positional('request-id', { describe: 'Active playtest request ID to replace', type: 'string', demandOption: true }), async argv => { + const overrides = await resolveMutationInput(argv, playtestRequestInput, () => editFlags(argv)) + if (argv.version === undefined) requireChanges(overrides) + + requireConfirmation(argv, 'Replacing a playtest request') + const gameID = String(argv.game) + const preflight = await readExpectedResource(api, gamePath(gameID), argv, { type: 'games', id: gameID }, 'game preflight') + const game = preflight.normalized as { + thumbnail?: string + thumbnail_url?: string + playtest_requests?: unknown[] + versions?: unknown[] + } + const activeRequests = asObjectArray(game.playtest_requests) + const current = activeRequests.find(request => String(request.id) === argv.requestId) + if (current === undefined) throw notFound('active playtest request', argv.requestId, 'Run `poki playtest-requests list` to see active request IDs.') + + const versionID = argv.version === undefined ? String(current.version_id ?? '') : String(argv.version) + if (versionID === '') throw inputError('The existing request has no version ID; pass --version explicitly.') + const versions = asObjectArray(game.versions) + if (!versions.some(version => String(version.id) === versionID)) { + throw inputError(`Version ${versionID} does not belong to game ${gameID}.`) + } + if (activeRequests.some(request => { + return String(request.id) !== argv.requestId && String(request.version_id) === versionID + })) { + throw inputError(`Version ${versionID} already has another active playtest request.`) + } + + const replacement: Record = { + // recordings counts the not-yet-started recordings and pending the ones + // in progress, so their sum is what the original request still owes. + // Delivered recordings appear in neither field and are not recreated. + recordings: Number(current.recordings ?? 0) + Number(current.pending ?? 0), + device_category: current.device_category ?? 'any', + categories: current.categories ?? '', + orientation: current.orientation ?? 'both', + new_users_only: current.new_users_only ?? false, + normal_tile: current.normal_tile ?? false, + ...overrides + } + validatePlaytestRequestData(replacement) + if (replacement.normal_tile === true && (game.thumbnail_url ?? game.thumbnail ?? '') === '') { + throw inputError('normal_tile requires the game to have a thumbnail.') + } + + const createAttributes = { + ...replacement, + game_id: argv.game, + version_id: versionID + } + const deletePath = gamePath(gameID, 'playtest-requests', argv.requestId) + const createPath = gamePath(gameID, 'playtest-requests') + if (argv.dryRun === true) { + render({ + dry_run: true, + contacted_api: true, + validation: { + scope: 'local_input_and_current_resource_state', + local_input_validated: true, + backend_mutation_validated: false, + mutation_permissions_validated: false, + resource_state_validated: true + }, + executable: 'unknown', + risk: 'non_atomic', + destructive: true, + non_atomic: true, + requests: [ + { method: 'DELETE', path: deletePath }, + { method: 'POST', path: createPath, body: jsonApiDocument('playtest_requests', createAttributes) } + ], + side_effects: ['The DELETE commits before the POST. A failed POST leaves the original request cancelled.'] + }, argv) + return + } + try { + const cancellation = await api.request({ + method: 'DELETE', + path: deletePath, + timeoutMs: requestTimeout(argv) + }) + // Although the DELETE normally returns an empty body, validate any + // body it does return before starting the non-atomic POST. A malformed + // 2xx cannot prove whether cancellation committed. + normalizeMutationResponse(cancellation.body, cancellation.status, 'DELETE', deletePath, { type: 'playtest_requests', id: String(argv.requestId) }) + } catch (error) { + const original = error instanceof CliError ? error : undefined + throw new CliError( + 'PLAYTEST_REQUEST_REPLACEMENT_CANCELLATION_FAILED', + 'The current state of the original request is unknown, so replacement creation was not attempted.', + original?.exitCode ?? 5, + { + status: original?.status, + retryable: false, + requestId: original?.requestId, + retryAfter: original?.retryAfter, + hint: `Run \`poki playtest-requests list --game ${gameID} --format json\` before any further mutation. Do not replay the DELETE or create a replacement blindly.`, + details: { + original_request_id: argv.requestId, + cancellation_state: 'unknown', + replacement_creation_state: 'not_attempted', + resolved_replacement: { + game_id: gameID, + version_id: versionID, + data: replacement + }, + recovery: cancellationRecovery(gameID, String(argv.requestId), versionID, replacement), + cause: safeErrorCause(error) + } + } + ) + } + + try { + const response = await api.request({ + method: 'POST', + path: createPath, + body: jsonApiDocument('playtest_requests', createAttributes), + timeoutMs: requestTimeout(argv) + }) + const normalizedCreated = normalizeMutationResponse(response.body, response.status, 'POST', createPath, { type: 'playtest_requests' }).data + if ( + normalizedCreated === null || + typeof normalizedCreated !== 'object' || + Array.isArray(normalizedCreated) || + (normalizedCreated as { type?: unknown }).type !== 'playtest_requests' || + typeof (normalizedCreated as { id?: unknown }).id !== 'string' + ) { + throw new CliError('INVALID_API_RESPONSE', 'The replacement response did not contain one playtest request resource.', 5, { + status: response.status, + retryable: false, + hint: 'The replacement POST may already have committed. Inspect current request state and do not replay it blindly.' + }) + } + if (argv.raw === true) { + render(response.body, argv) + return + } + writeStructured({ + data: { + cancelled_request_id: argv.requestId, + replacement: normalizedCreated, + atomic: false + }, + meta: {} + }, argv.format === 'json' ? 'json' : 'toon') + } catch (error) { + const original = error instanceof CliError ? error : undefined + const creationState = replacementCreationState(error) + throw new CliError( + 'PLAYTEST_REQUEST_REPLACEMENT_FAILED', + creationState === 'not_created' + ? 'The original request was cancelled, and the backend rejected its replacement without creating it.' + : 'The original request was cancelled, but the outcome of the replacement POST is unknown.', + original?.exitCode ?? 5, + { + status: original?.status, + retryable: false, + requestId: original?.requestId, + retryAfter: original?.retryAfter, + hint: creationState === 'unknown' + ? `Run \`poki playtest-requests list --game ${gameID} --format json\` and inspect current request state before any create attempt. Do not retry the POST blindly.` + : 'Correct the rejection cause, then use details.recovery.create_replacement without repeating the cancellation.', + details: { + cancelled_request_id: argv.requestId, + replacement_creation_state: creationState, + ...(creationState === 'not_created' ? { replacement_not_created: true } : {}), + recovery: replacementRecovery(gameID, versionID, replacement, creationState), + cause: safeErrorCause(error) + } + } + ) + } + }) + .demandCommand(1, 'Choose playtest-requests list, create, cancel, or replace.'), () => {}) +} diff --git a/src/commands/playtests.ts b/src/commands/playtests.ts new file mode 100644 index 0000000..c211465 --- /dev/null +++ b/src/commands/playtests.ts @@ -0,0 +1,247 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { playtestsDocumentation } from '../docs/resources' +import { inputError } from '../errors' +import { readStructuredSource, requireAllowedFields, requireChanges } from '../input' +import { isRecord } from '../json' +import { jsonApiDocument } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { registerResourceDiscovery } from './resource-docs' +import { + applyAudienceInputDefaults, + audienceInputFromFlags, + audienceOrientations, + deviceCategories, + validateAudienceInput +} from './audience-input' +import { + asStrings, + ensureDataExclusive, + gamePath, + getFromCollection, + listResources, + mutationInputFields, + mutationPreview, + mutateResource, + readExpectedResource, + render, + renderList, + renderMutation, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withGameActionOptions, + withGameMutationOptions, + withListOptions, + withOutputOptions +} from './common' + +// playtest-requests replace edits exactly the fields create accepts, so both +// commands share one declaration rather than keeping a second copy in step. +export const playtestRequestInput = mutationInputFields({ + recordings: 'recordings', + deviceCategory: 'device_category', + category: 'categories', + orientation: 'orientation', + newUsersOnly: 'new_users_only', + normalTile: 'normal_tile' +}) + +export function validatePlaytestRequestData (data: Record): void { + if (typeof data.recordings !== 'number' || !Number.isInteger(data.recordings) || data.recordings < 1 || data.recordings > 10) { + throw inputError('recordings must be an integer from 1 through 10.') + } + validateAudienceInput(data) + for (const field of ['new_users_only', 'normal_tile']) { + if (data[field] !== undefined && typeof data[field] !== 'boolean') throw inputError(`${field} must be a boolean.`) + } +} + +async function buildPlaytestRequestData (argv: Record): Promise> { + const data = await resolveMutationInput(argv, playtestRequestInput, () => ({ + recordings: argv.recordings ?? 10, + ...audienceInputFromFlags(argv, { defaults: true }), + new_users_only: argv.newUsersOnly ?? false, + normal_tile: argv.normalTile ?? false + })) + data.recordings ??= 10 + applyAudienceInputDefaults(data) + data.new_users_only ??= false + data.normal_tile ??= false + validatePlaytestRequestData(data) + return data +} + +export function withPlaytestRequestOptions ( + yargs: Argv, + projectGameId: string | undefined, + requireVersion = true, + behavior: { nonAtomic?: boolean } = {} +): Argv { + return withGameMutationOptions(withDataOption(yargs, 'JSON or TOON request-settings object, @file, or - for stdin; version remains a flag'), projectGameId, 'Game ID that owns the requested version', behavior) + .option('version', { + describe: requireVersion ? 'Version ID to record' : 'Replacement version ID; omit to keep the current version', + type: 'string', + demandOption: requireVersion + }) + .option('recordings', { describe: 'Number of recordings to request (1-10, default 10)', type: 'number' }) + .option('device-category', { describe: 'Device audience', choices: deviceCategories }) + .option('orientation', { describe: 'Required screen orientation', choices: audienceOrientations }) + .option('category', { describe: 'Numeric audience category ID; repeat for multiple categories', type: 'array', string: true }) + .option('new-users-only', { describe: 'Only recruit new users', type: 'boolean' }) + .option('normal-tile', { describe: 'Recruit through the normal game tile; requires a game thumbnail', type: 'boolean' }) +} + +export async function createPlaytestRequest ( + api: ApiClient, + argv: Record +): Promise { + if (typeof argv.game !== 'string' || typeof argv.version !== 'string') { + throw inputError('--game and --version are required.') + } + const data = await buildPlaytestRequestData(argv) + // The thumbnail check needs a live GET, so --dry-run defers it to execution + // and stays offline. + if (data.normal_tile === true && argv.dryRun !== true) { + const preflight = await readExpectedResource(api, gamePath(argv.game), argv, { type: 'games', id: argv.game }, 'game preflight') + const game = preflight.normalized as { + thumbnail?: string + thumbnail_url?: string + } + if ((game.thumbnail_url ?? game.thumbnail ?? '') === '') { + throw inputError('--normal-tile requires the game to have a thumbnail.') + } + } + + const attributes = { ...data, game_id: argv.game, version_id: argv.version } + const body = jsonApiDocument('playtest_requests', attributes) + const path = gamePath(argv.game, 'playtest-requests') + if (mutationPreview('POST', path, body, argv, { + sideEffects: [ + 'Creates a playtest request and may advance the game self-service stage.', + ...(data.normal_tile === true ? ['Execution first verifies with one GET that the game has a thumbnail (--normal-tile).'] : []) + ] + })) return undefined + return await mutateResource(api, 'POST', path, body, argv, { type: 'playtest_requests' }) +} + +function recordingPath (game: unknown, recording: unknown, suffix = ''): string { + return `${gamePath(game, 'playtest-recordings', recording)}${suffix}` +} + +function recordingUrls (id: string): Record<'video_url' | 'metadata_json_url', string> { + const encodedId = encodeURIComponent(id) + return { + video_url: `https://storage.googleapis.com/poki-playtest-recordings/${encodedId}.webm`, + metadata_json_url: `https://storage.googleapis.com/poki-playtest-recordings/${encodedId}.json` + } +} + +function decorateRecordingResource (resource: unknown, raw: boolean): unknown { + if (!isRecord(resource) || resource.type !== 'playtest_recordings' || typeof resource.id !== 'string' || resource.id.trim() === '') return resource + const urls = recordingUrls(resource.id) + if (!raw) return { ...resource, ...urls } + + // Raw Playtest reads deliberately keep the backend JSON:API resource shape + // while ensuring these essential CLI-derived attributes exist. A malformed + // attributes container has no preservable JSON:API fields, so replace it. + return { + ...resource, + attributes: { + ...(isRecord(resource.attributes) ? resource.attributes : {}), + ...urls + } + } +} + +function decorateRecordingDocument (document: unknown, raw: boolean): unknown { + if (!isRecord(document) || !Object.prototype.hasOwnProperty.call(document, 'data')) return document + const data = Array.isArray(document.data) + ? document.data.map(resource => decorateRecordingResource(resource, raw)) + : decorateRecordingResource(document.data, raw) + return { ...document, data } +} + +export function registerPlaytestCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('playtest-recordings', 'List, inspect, assess, archive, and mark recorded playtests watched', recordings => registerResourceDiscovery(recordings, playtestsDocumentation) + .command('list', 'List recordings for a game', list => withDefaultGameOption(withListOptions(list, listCapabilities.playtests, 'playtests'), projectGameId, 'Game whose recordings to read') + .option('version', { describe: 'Only return recordings for this version ID', type: 'string' }) + .option('archived', { describe: 'Select active, archived, or all recordings', choices: ['active', 'archived', 'all'] as const, default: 'active' }), async argv => { + const extra: Array<[string, string]> = [] + if (argv.version !== undefined) extra.push(['version_id', argv.version]) + if (argv.archived === 'active') extra.push(['archived_at', 'null']) + if (argv.archived === 'archived') extra.push(['archived_at', 'not:null']) + const response = await listResources(api, gamePath(argv.game, 'playtest-recordings'), argv, listCapabilities.playtests, extra) + renderList(decorateRecordingDocument(response, argv.raw === true), argv, 'playtests') + }) + .command('get ', 'Get one recording with stable video and metadata URLs', get => withDefaultGameOption(withOutputOptions(get), projectGameId, 'Game that owns the recording') + .positional('recording-id', { describe: 'Playtest recording ID', type: 'string', demandOption: true }), async argv => { + const response = await getFromCollection(api, gamePath(argv.game, 'playtest-recordings'), argv, listCapabilities.playtests, 'playtest_recordings.id', { type: 'playtest_recordings', id: String(argv.recordingId) }, { + label: 'playtest recording', + hint: 'Run `poki playtest-recordings list` to see visible recording IDs.' + }) + render(decorateRecordingDocument(response, argv.raw === true), argv) + }) + .command('update ', 'Replace the editable tag list on a recording', update => withGameMutationOptions(withDataOption(update, 'JSON or TOON object containing only tags, inline, from @file, or stdin'), projectGameId, 'Game that owns the recording') + .positional('recording-id', { describe: 'Playtest recording ID', type: 'string', demandOption: true }) + .option('tag', { describe: 'Tag name; repeat to set multiple tags', type: 'array', string: true }) + .option('clear-tags', { describe: 'Replace the tag list with an empty list', type: 'boolean', default: false }), async argv => { + ensureDataExclusive(argv, ['tag']) + if (argv.clearTags && (argv.data !== undefined || argv.tag !== undefined)) { + throw inputError('--clear-tags cannot be combined with --tag or --data.') + } + if (argv.data === undefined && argv.tag === undefined && !argv.clearTags) { + throw inputError('Provide --tag (repeatable) to set tags, --clear-tags to remove every tag, or --data.', { + accepted_inputs: ['--tag NAME', '--clear-tags', '--data @tags.json'] + }) + } + // yargs parses a valueless --tag (an unset shell variable, or a --tag + // immediately followed by another flag) as an empty array. Clearing the + // complete tag list stays an explicit --clear-tags decision. + if (Array.isArray(argv.tag) && argv.tag.length === 0) { + throw inputError('--tag requires a tag name; use --clear-tags to remove every tag.', { + accepted_inputs: ['--tag NAME', '--clear-tags'] + }) + } + const data = argv.data !== undefined + ? await readStructuredSource(String(argv.data)) + : { tags: argv.clearTags ? [] : asStrings(argv.tag) ?? [] } + requireAllowedFields(data, ['tags']) + requireChanges(data) + if (!Array.isArray(data.tags) || data.tags.some(tag => typeof tag !== 'string' || tag.trim() === '')) { + throw inputError('tags must be an array of non-empty strings.') + } + const id = String(argv.recordingId) + const body = jsonApiDocument('playtest_recordings', data, id) + await renderMutation(api, argv, { method: 'PATCH', path: recordingPath(argv.game, id), body, expected: { type: 'playtest_recordings', id }, behavior: { sideEffects: ['Replaces the recording tag list.'] }, action: { result: { id, tags: data.tags }, preferResponse: true } }) + }) + .command('skip-assessment ', 'Clear assessment tags and mark a recording assessment skipped', skip => withGameMutationOptions(skip, projectGameId, 'Game that owns the recording', { destructive: true }) + .positional('recording-id', { describe: 'Playtest recording ID', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Skipping a playtest recording assessment') + const id = String(argv.recordingId) + const attributes = { tags: [], skipped_assessment: true } + const body = jsonApiDocument('playtest_recordings', attributes, id) + await renderMutation(api, argv, { method: 'PATCH', path: recordingPath(argv.game, id), body, expected: { type: 'playtest_recordings', id }, behavior: { destructive: true, sideEffects: ['Clears assessment tags and permanently records that assessment was skipped.'] }, action: { result: { id, ...attributes }, preferResponse: true } }) + }) + .command('archive ', 'Archive a recording', archive => withGameActionOptions(archive, projectGameId, 'Game that owns the recording') + .positional('recording-id', { describe: 'Playtest recording ID', type: 'string', demandOption: true }), async argv => { + const id = String(argv.recordingId) + await renderMutation(api, argv, { method: 'POST', path: recordingPath(argv.game, id, '/@archive'), expected: { type: 'playtest_recordings', id }, behavior: { sideEffects: ['Moves the recording out of active lists.'] }, action: { result: { id, archived: true } } }) + }) + .command('unarchive ', 'Restore an archived recording', unarchive => withGameActionOptions(unarchive, projectGameId, 'Game that owns the recording') + .positional('recording-id', { describe: 'Playtest recording ID', type: 'string', demandOption: true }), async argv => { + const id = String(argv.recordingId) + await renderMutation(api, argv, { method: 'POST', path: recordingPath(argv.game, id, '/@unarchive'), expected: { type: 'playtest_recordings', id }, behavior: { sideEffects: ['Returns the recording to active lists.'] }, action: { result: { id, archived: false } } }) + }) + .command('watch ', 'Mark a recording watched for the authenticated user', watch => withGameActionOptions(watch, projectGameId, 'Game that owns the recording') + .positional('recording-id', { describe: 'Playtest recording ID', type: 'string', demandOption: true }), async argv => { + const id = String(argv.recordingId) + await renderMutation(api, argv, { method: 'POST', path: recordingPath(argv.game, id, '/@watch'), expected: { type: 'playtest_recordings', id }, behavior: { sideEffects: ['Marks the recording watched for the current user.'] }, action: { result: { id, watched: true } } }) + }) + .demandCommand(1, 'Choose playtest-recordings list, get, update, skip-assessment, archive, unarchive, or watch.'), () => {}) +} diff --git a/src/commands/polling.ts b/src/commands/polling.ts new file mode 100644 index 0000000..91d7a04 --- /dev/null +++ b/src/commands/polling.ts @@ -0,0 +1,144 @@ +import type { Argv } from 'yargs' + +import { CliError, inputError } from '../errors' +import { + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_WAIT_TIMEOUT_MS, + MAX_TIMEOUT_MS, + parseTimeoutMilliseconds, + TIMEOUT_MILLISECONDS_RANGE +} from '../timeouts' + +export function withWaitOptions (yargs: Argv, waitDescription: string): Argv { + return yargs + .option('wait', { describe: waitDescription, type: 'boolean', default: false }) + .option('poll-interval-ms', { describe: `Delay between --wait polls; accepts an ${TIMEOUT_MILLISECONDS_RANGE}`, type: 'number', default: DEFAULT_POLL_INTERVAL_MS }) + .option('wait-timeout-ms', { describe: `Maximum total --wait time before a retryable WAIT_TIMEOUT error; accepts an ${TIMEOUT_MILLISECONDS_RANGE}`, type: 'number', default: DEFAULT_WAIT_TIMEOUT_MS }) + .check(argv => { + if (parseTimeoutMilliseconds(argv.pollIntervalMs) === undefined) throw inputError(`--poll-interval-ms must be a positive integer no greater than ${String(MAX_TIMEOUT_MS)}.`) + if (parseTimeoutMilliseconds(argv.waitTimeoutMs) === undefined) throw inputError(`--wait-timeout-ms must be a positive integer no greater than ${String(MAX_TIMEOUT_MS)}.`) + if (argv.wait && argv.raw === true) throw inputError('--wait cannot be combined with --raw.') + return true + }) +} + +export interface PollOutcome { + resource: unknown + state: string + polls: number + waited_ms: number +} + +// Bounded polling loop for asynchronous server-side processing. On timeout +// the operation is still running remotely, so the error is retryable and +// carries the last observed state. +export async function pollUntil ( + args: Record, + fetchState: (pollTimeoutMs: number) => Promise<{ resource: unknown, state: string, terminal: boolean, succeeded: boolean }>, + subject: string, + // The timeout one poll request would use outside --wait: the explicit + // --timeout-ms, else POKI_API_TIMEOUT_MS, else the ordinary default. Each + // poll is bounded by this or the remaining wait time, whichever is smaller, + // and which of the two bounds applies decides how an expiry is reported. + requestTimeoutMs: number +): Promise { + const intervalMs = parseTimeoutMilliseconds(args.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS + const timeoutMs = parseTimeoutMilliseconds(args.waitTimeoutMs) ?? DEFAULT_WAIT_TIMEOUT_MS + const startedAt = Date.now() + const deadline = startedAt + timeoutMs + let polls = 0 + let lastResource: unknown = null + let lastState = 'unknown' + + const timeoutError = (): CliError => { + const waitedMs = Date.now() - startedAt + return new CliError('WAIT_TIMEOUT', `Timed out waiting for ${subject} after ${String(waitedMs)} ms; last observed state: ${lastState}.`, 5, { + details: { last_state: lastState, polls, waited_ms: waitedMs, resource: lastResource }, + retryable: true, + hint: 'The operation continues server-side; poll the resource again or raise --wait-timeout-ms.' + }) + } + + const waitFor = async (milliseconds: number): Promise => { + await new Promise(resolve => setTimeout(resolve, milliseconds)) + } + + while (true) { + const remainingBeforePoll = deadline - Date.now() + if (remainingBeforePoll <= 0) throw timeoutError() + const pollTimeoutMs = Math.max(1, Math.min(remainingBeforePoll, requestTimeoutMs)) + // When the remaining wait time is the smaller bound, an expiring request + // means the wait deadline expired. Both timers are then armed for the same + // instant, so without this the winner of that race would decide between + // the retryable WAIT_TIMEOUT contract and a bare API_TIMEOUT. A poll the + // request timeout bounds keeps reporting API_TIMEOUT, so a create that + // already committed keeps its no-replay recovery envelope. + const boundedByDeadline = remainingBeforePoll <= requestTimeoutMs + const deadlineReached = Symbol('poll-deadline-reached') + let timer: ReturnType | undefined + let current: { resource: unknown, state: string, terminal: boolean, succeeded: boolean } + try { + current = await Promise.race([ + fetchState(pollTimeoutMs), + new Promise(resolve => { + timer = setTimeout(() => resolve(deadlineReached), remainingBeforePoll) + }) + ]).then(result => { + if (result === deadlineReached) throw timeoutError() + return result + }) + } catch (error) { + // Only an expiry becomes WAIT_TIMEOUT. A definitive failure such as a 403 + // that happens to surface once the deadline has passed is not a timeout, + // and rewriting it as the retryable wait contract would send an agent + // into a retry loop against a request that can never succeed. + const expiredPoll = error instanceof CliError && (error.code === 'WAIT_TIMEOUT' || (boundedByDeadline && error.code === 'API_TIMEOUT')) + if (expiredPoll) throw timeoutError() + throw error + } finally { + if (timer !== undefined) clearTimeout(timer) + } + if (Date.now() >= deadline) throw timeoutError() + polls++ + lastResource = current.resource + lastState = current.state + const waitedMs = Date.now() - startedAt + if (current.terminal) { + if (!current.succeeded) { + throw new CliError('ASYNC_OPERATION_FAILED', `${subject} reached terminal failure state ${current.state}.`, 5, { + details: { final_state: current.state, polls, waited_ms: waitedMs, resource: current.resource }, + retryable: false, + hint: 'Inspect the final resource error details; retry only by starting a new operation when appropriate.' + }) + } + return { resource: current.resource, state: current.state, polls, waited_ms: waitedMs } + } + const remainingBeforeDelay = deadline - Date.now() + if (remainingBeforeDelay <= 0) throw timeoutError() + if (intervalMs >= remainingBeforeDelay) { + // A timer may wake just before its requested deadline on some platforms. + // Keep waiting for the absolute deadline instead of starting another + // request with only a rounding sliver of the wait budget remaining. + let remaining = remainingBeforeDelay + while (remaining > 0) { + await waitFor(remaining) + remaining = deadline - Date.now() + } + throw timeoutError() + } + await waitFor(intervalMs) + } +} + +export function withWaitMeta (outcome: PollOutcome): unknown { + const resource = outcome.resource + if (resource === null || typeof resource !== 'object' || Array.isArray(resource)) return resource + const meta = (resource as { meta?: Record }).meta + return { + ...resource, + meta: { + ...(meta ?? {}), + wait: { final_state: outcome.state, polls: outcome.polls, waited_ms: outcome.waited_ms } + } + } +} diff --git a/src/commands/rendering.ts b/src/commands/rendering.ts new file mode 100644 index 0000000..06b8458 --- /dev/null +++ b/src/commands/rendering.ts @@ -0,0 +1,40 @@ +import { isRecord } from '../json' +import { inputError } from '../errors' +import { csvString, writeStructured } from '../output' +import { applyListView, listViewColumns, ResourceListKind } from '../views' + +export function render (value: unknown, args: Record): void { + writeStructured(value, args.format === 'json' ? 'json' : 'toon') +} + +export function renderList ( + value: unknown, + args: Record, + kind: ResourceListKind +): void { + const view = { + full: args.full === true, + fields: typeof args.fields === 'string' ? args.fields : undefined + } + const viewed = applyListView(value, { raw: args.raw === true, ...view }, kind) + if (args.format === 'csv') { + const meta = isRecord(viewed) ? viewed.meta : undefined + // A bounded --all reports truncated; an ordinary numbered page reports only + // has_next. Both mean resources are missing, and the CSV rows carry neither + // signal, so an incomplete export must fail closed either way. + if (isRecord(meta) && (meta.truncated === true || meta.has_next === true)) { + throw inputError('--format csv cannot represent pagination truncation metadata, so this incomplete result cannot be exported. Rerun as JSON or TOON to read the pagination metadata, or use --all with bounds that return a complete result.', { + pagination: meta + }) + } + const data = (viewed as { data?: unknown }).data + // An empty collection has no rows to derive a header from, so the columns + // come from the same view definition applyListView projects the rows with. + process.stdout.write(csvString( + Array.isArray(data) ? data as Array> : [], + listViewColumns(kind, view) + )) + return + } + render(viewed, args) +} diff --git a/src/commands/resource-docs.ts b/src/commands/resource-docs.ts new file mode 100644 index 0000000..5107dc9 --- /dev/null +++ b/src/commands/resource-docs.ts @@ -0,0 +1,36 @@ +import type { Argv } from 'yargs' + +import { withFormatOption } from './common' +import { ResourceDocumentation, resourceFieldDetails, resourceFieldIndex } from '../docs/resources' +import { inputError } from '../errors' +import { structuredFormat, writeStructured } from '../output' +import { RESOURCE_API_TIME_ZONE } from '../timezones' + +export function registerResourceDiscovery (yargs: Argv, documentation: ResourceDocumentation): Argv { + return yargs + .command('fields', `List the documented ${documentation.resource} fields`, fields => withFormatOption(fields), argv => { + const fields = resourceFieldIndex(documentation) + writeStructured({ + data: { + resource: documentation.resource, + fields, + references: documentation.references + }, + meta: { total: fields.length, timestamp_time_zone: RESOURCE_API_TIME_ZONE } + }, structuredFormat(argv.format)) + }) + .command('field ', `Describe one ${documentation.resource} field`, field => withFormatOption(field) + .positional('name', { + describe: 'Exact field name returned by the fields command', + type: 'string', + demandOption: true + }), argv => { + const details = resourceFieldDetails(documentation, argv.name) + if (details === undefined) { + throw inputError(`Unknown ${documentation.resource} field '${argv.name}'.`, { + available_fields: documentation.fields.map(field => field.name) + }) + } + writeStructured({ data: details, meta: {} }, structuredFormat(argv.format)) + }) +} diff --git a/src/commands/resource-responses.ts b/src/commands/resource-responses.ts new file mode 100644 index 0000000..650d8a9 --- /dev/null +++ b/src/commands/resource-responses.ts @@ -0,0 +1,286 @@ +import { ApiClient } from '../api' +import { CliError } from '../errors' +import { + jsonApiPrimaryResource, + jsonApiPrimaryResourceIdentity, + jsonValueKind, + normalizeJsonApi, + normalizeJsonApiResource, + normalizeJsonApiResourceForRecovery, + ResourceResult +} from '../jsonapi' +import { isRecord } from '../json' +import { MutationBehavior, mutationPreview, requestTimeout } from './command-options' +import { render } from './rendering' + +export interface ExpectedJsonApiResource { + type: string + id?: string +} + +export interface ExpectedJsonApiResourceResult { + raw: Record + normalized: Record + document: ResourceResult +} + +export interface NullableExpectedJsonApiResourceResult { + raw: Record | null + normalized: Record | null + document: ResourceResult +} + +interface ExpectedResourceIdentityCheck { + type: unknown + id: unknown + typeMatches: boolean + idIsUsable: boolean + idMatches: boolean + valid: boolean +} + +function checkExpectedResourceIdentity ( + resource: { type?: unknown, id?: unknown } | null, + expected: ExpectedJsonApiResource +): ExpectedResourceIdentityCheck { + const type = resource?.type + const id = resource?.id + const typeMatches = type === expected.type + const idIsUsable = typeof id === 'string' && id.trim() !== '' + const idMatches = idIsUsable && (expected.id === undefined || id === expected.id) + return { + type, + id, + typeMatches, + idIsUsable, + idMatches, + valid: resource !== null && typeMatches && idMatches + } +} + +function expectedResourceIdentityDetails ( + expected: ExpectedJsonApiResource, + identity: ExpectedResourceIdentityCheck, + receivedPrimaryDataKind: string +): Record { + return { + expected_resource_type: expected.type, + ...(expected.id === undefined ? { expected_resource_id_kind: 'non_empty_string' } : { expected_resource_id: expected.id }), + received_primary_data_kind: receivedPrimaryDataKind, + received_resource_type_kind: jsonValueKind(identity.type), + received_resource_type_matches: identity.typeMatches, + received_resource_id_kind: jsonValueKind(identity.id), + received_resource_id_usable: identity.idIsUsable, + ...(expected.id === undefined ? {} : { received_resource_id_matches: identity.idMatches }) + } +} + +export function responseLocation (body: unknown, description: string): string { + if (body === null || typeof body !== 'object' || Array.isArray(body) || typeof (body as { location?: unknown }).location !== 'string') { + throw new CliError('INVALID_API_RESPONSE', `The Poki API did not return ${description}.`, 5, { + details: { expected: 'object_with_string_location', received_kind: jsonValueKind(body) } + }) + } + const location = (body as { location: string }).location + if (location.trim() === '') { + throw new CliError('INVALID_API_RESPONSE', `The Poki API did not return ${description}.`, 5, { + details: { expected: 'non_empty_location_string', received_kind: 'empty_string' } + }) + } + return location +} + +export function isMalformedSuccessfulMutation (error: unknown): error is CliError { + return error instanceof CliError && error.code === 'INVALID_API_RESPONSE' && + error.status !== undefined && error.status >= 200 && error.status < 300 +} + +export function normalizeMutationResponse ( + body: unknown, + status: number, + method: 'POST' | 'PATCH' | 'DELETE', + path: string, + expected: ExpectedJsonApiResource, + onRecoverySnapshot?: (result: ResourceResult) => void +): ResourceResult { + let normalized: ResourceResult + try { + normalized = normalizeJsonApi(body, undefined, undefined, 'singular') + } catch (error) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned a malformed JSON:API document after a successful mutation response.', 5, { + status, + details: { + method, + path, + expected_primary_data: 'singular', + received_document_kind: jsonValueKind(body), + received_primary_data_member: isRecord(body) && Object.prototype.hasOwnProperty.call(body, 'data') + ? 'present' + : 'missing', + ...(isRecord(body) && Object.prototype.hasOwnProperty.call(body, 'data') + ? { received_primary_data_kind: jsonValueKind((body as { data?: unknown }).data) } + : {}) + }, + retryable: false, + hint: `The ${method} mutation may already have committed. Inspect current resource state and do not replay it blindly.` + }) + } + + // Async create wrappers use this sanitized snapshot for inspect-before- + // replay recovery when the document is valid but its resource identity is + // unusable. It is deliberately captured before identity validation and is + // never substituted for a successful command result. + onRecoverySnapshot?.(normalizeJsonApiResourceForRecovery(body)) + + if (normalized.data !== null) { + const resource = jsonApiPrimaryResourceIdentity(body) ?? { type: undefined, id: undefined } + const identity = checkExpectedResourceIdentity(resource, expected) + if (!identity.valid) { + throw new CliError('INVALID_API_RESPONSE', 'The Poki API returned an unexpected resource identity after a successful mutation response.', 5, { + status, + details: { + method, + path, + ...expectedResourceIdentityDetails(expected, identity, jsonValueKind(normalized.data)) + }, + retryable: false, + hint: `The ${method} mutation may already have committed. Inspect current resource state and do not replay it blindly.` + }) + } + } + return normalized +} + +export function requireExpectedJsonApiResource ( + body: unknown, + expected: ExpectedJsonApiResource, + description: string, + options?: { allowNull?: false } +): ExpectedJsonApiResourceResult +export function requireExpectedJsonApiResource ( + body: unknown, + expected: ExpectedJsonApiResource, + description: string, + options: { allowNull: true } +): NullableExpectedJsonApiResourceResult +export function requireExpectedJsonApiResource ( + body: unknown, + expected: ExpectedJsonApiResource, + description: string, + options: { allowNull?: boolean } = {} +): ExpectedJsonApiResourceResult | NullableExpectedJsonApiResourceResult { + // Read and check the resource before normalization. A malformed known field + // can deliberately collapse normalized output to identity-only; that must + // never make state appear absent to a caller preparing a mutation. + const raw = jsonApiPrimaryResource(body) + const document = normalizeJsonApiResource(body) + if (raw === null && options.allowNull === true) { + return { raw: null, normalized: null, document } + } + const identity = checkExpectedResourceIdentity(raw, expected) + if (!identity.valid) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response did not contain the requested resource.`, 5, { + details: expectedResourceIdentityDetails(expected, identity, raw === null ? 'null' : 'object'), + retryable: false + }) + } + + const normalized = document.data + if (normalized === null || typeof normalized !== 'object' || Array.isArray(normalized)) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response did not contain one resource.`, 5, { + details: { received_primary_data_kind: jsonValueKind(normalized) }, + retryable: false + }) + } + return { raw, normalized: normalized as Record, document } +} +export async function getResource ( + api: ApiClient, + path: string, + args: Record, + expected?: ExpectedJsonApiResource, + description = 'resource read' +): Promise { + const response = await api.request({ path, timeoutMs: requestTimeout(args) }) + if (args.raw === true) return response.body + return expected === undefined + ? normalizeJsonApiResource(response.body) + : requireExpectedJsonApiResource(response.body, expected, description, { allowNull: true }).document +} + +// Commands that need the raw resource, the normalized resource, and the +// document - a mutation preflight, a readiness read, a degraded-field report - +// cannot use getResource, which returns only one of the three. They still must +// read through the applicable request timeout and validate resource identity +// before deriving state from the response. +export async function readExpectedResource ( + api: ApiClient, + path: string, + args: Record, + expected: ExpectedJsonApiResource, + description: string +): Promise { + const response = await api.request({ path, timeoutMs: requestTimeout(args) }) + return requireExpectedJsonApiResource(response.body, expected, description) +} + +export async function mutateResource ( + api: ApiClient, + method: 'POST' | 'PATCH' | 'DELETE', + path: string, + body: unknown, + args: Record, + expected: ExpectedJsonApiResource +): Promise { + const response = await api.request({ method, path, body, timeoutMs: requestTimeout(args) }) + const normalized = normalizeMutationResponse(response.body, response.status, method, path, expected) + return args.raw === true ? response.body : normalized +} + +export async function mutateAction ( + api: ApiClient, + method: 'POST' | 'PATCH' | 'DELETE', + path: string, + body: unknown, + args: Record, + expected: ExpectedJsonApiResource, + fallbackData: Record, + options: { preferResponse?: boolean } = {} +): Promise { + const response = await api.request({ method, path, body, timeoutMs: requestTimeout(args) }) + const normalized = normalizeMutationResponse(response.body, response.status, method, path, expected) + if (args.raw === true) return response.body + if (options.preferResponse === true && response.body !== null) return normalized + return { data: fallbackData, meta: {} } +} + +export interface RenderedMutation { + method: 'POST' | 'PATCH' | 'DELETE' + path: string + body?: unknown + // The resource identity a successful mutation response must carry. + expected: ExpectedJsonApiResource + behavior?: MutationBehavior + // Stands in for the resolved body in a dry-run preview when echoing the real + // one would print an encoded file. + previewBody?: unknown + // Actions whose successful response is normally empty report this result + // instead; preferResponse returns the response when the backend sent one. + action?: { result: Record, preferResponse?: boolean } +} + +// A dry-run preview must describe the request the command would actually send. +// Naming the method, path, body, and resource identity once for the preview and +// again for the execution let the two drift apart, which would make --dry-run +// report a request the command never sends. +export async function renderMutation ( + api: ApiClient, + args: Record, + mutation: RenderedMutation +): Promise { + const { method, path, body, expected, action } = mutation + if (mutationPreview(method, path, mutation.previewBody ?? body, args, mutation.behavior)) return + render(action === undefined + ? await mutateResource(api, method, path, body, args, expected) + : await mutateAction(api, method, path, body, args, expected, action.result, { preferResponse: action.preferResponse }), args) +} diff --git a/src/commands/reviews.ts b/src/commands/reviews.ts new file mode 100644 index 0000000..72bd110 --- /dev/null +++ b/src/commands/reviews.ts @@ -0,0 +1,98 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { reviewsDocumentation } from '../docs/resources' +import { inputError } from '../errors' +import { containsZeroWidthCharacter, requireChanges } from '../input' +import { jsonApiDocument } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { registerResourceDiscovery } from './resource-docs' +import { + gamePath, + listResources, + mutationInputFields, + render, + renderList, + renderMutation, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withGameMutationOptions, + withListOptions, + withOutputOptions, + getResource +} from './common' + +const requestInput = mutationInputFields({ developerNotes: 'developer_notes' }) +const updateInput = mutationInputFields({ developerNotes: 'developer_notes', seen: 'seen_by_developer' }) + +function reviewPath (game: unknown, version: unknown, review?: unknown): string { + return gamePath(game, 'versions', version, 'reviews', ...(review === undefined ? [] : [review])) +} + +function validateReviewUpdate (data: Record): void { + if (Object.hasOwn(data, 'developer_notes') && typeof data.developer_notes !== 'string') { + throw inputError('developer_notes must be a string.') + } + if (Object.hasOwn(data, 'seen_by_developer') && typeof data.seen_by_developer !== 'boolean') { + throw inputError('seen_by_developer must be a boolean.') + } + if (typeof data.developer_notes === 'string' && containsZeroWidthCharacter(data.developer_notes)) { + throw inputError('developer_notes must not contain zero-width characters.') + } +} + +export function registerReviewCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('reviews', 'List, inspect, request, update, and close version reviews', reviews => registerResourceDiscovery(reviews, reviewsDocumentation) + .command('list', 'List reviews for a game or one version', list => withDefaultGameOption(withListOptions(list, listCapabilities.reviews, 'reviews'), projectGameId, 'Parent game ID') + .option('version', { describe: 'Restrict to one version ID; omit to list reviews across the game', type: 'string' }), async argv => { + const path = argv.version === undefined + ? gamePath(argv.game, 'reviews') + : reviewPath(argv.game, argv.version) + renderList(await listResources(api, path, argv, listCapabilities.reviews), argv, 'reviews') + }) + .command('get ', 'Get one review including report metadata visible to the caller', get => withDefaultGameOption(withOutputOptions(get), projectGameId, 'Parent game ID') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }) + .positional('review-id', { describe: 'Review ID', type: 'string', demandOption: true }), async argv => { + const reviewID = String(argv.reviewId) + render(await getResource(api, reviewPath(argv.game, argv.versionId, reviewID), argv, { type: 'reviews', id: reviewID }, 'review read'), argv) + }) + .command('request', 'Request a review for a processed version', request => withGameMutationOptions(withDataOption(request, 'JSON or TOON object containing developer_notes'), projectGameId, 'Parent game ID') + .option('version', { describe: 'Processed version ID to review', type: 'string', demandOption: true }) + .option('developer-notes', { describe: 'Required notes for the reviewer', type: 'string' }), async argv => { + const data = await resolveMutationInput(argv, requestInput, () => ({ developer_notes: argv.developerNotes })) + if (typeof data.developer_notes !== 'string' || data.developer_notes.trim() === '') throw inputError('developer_notes is required.') + if (containsZeroWidthCharacter(data.developer_notes)) throw inputError('developer_notes must not contain zero-width characters.') + const path = reviewPath(argv.game, argv.version) + const body = jsonApiDocument('reviews', data) + await renderMutation(api, argv, { method: 'POST', path, body, expected: { type: 'reviews' }, behavior: { sideEffects: ['Queues QA and notifies game watchers.'] } }) + }) + .command('update ', 'Update developer notes or mark a review response seen', update => withGameMutationOptions(withDataOption(update, 'JSON or TOON object containing developer_notes and/or seen_by_developer'), projectGameId, 'Parent game ID') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }) + .positional('review-id', { describe: 'Review ID', type: 'string', demandOption: true }) + .option('developer-notes', { describe: 'Replacement developer notes', type: 'string' }) + .option('seen', { describe: 'Set seen_by_developer', type: 'boolean' }), async argv => { + const data = await resolveMutationInput(argv, updateInput, () => ({ + ...(argv.developerNotes === undefined ? {} : { developer_notes: argv.developerNotes }), + ...(argv.seen === undefined ? {} : { seen_by_developer: argv.seen }) + })) + requireChanges(data) + validateReviewUpdate(data) + const path = reviewPath(argv.game, argv.versionId, argv.reviewId) + const body = jsonApiDocument('reviews', data, String(argv.reviewId)) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'reviews', id: String(argv.reviewId) }, behavior: { sideEffects: ['Updates the review and audit history.'] } }) + }) + .command('close ', 'Close a pending review without approving or rejecting it', close => withGameMutationOptions(close, projectGameId, 'Parent game ID', { destructive: true }) + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }) + .positional('review-id', { describe: 'Pending review ID', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Closing a review') + const path = reviewPath(argv.game, argv.versionId, argv.reviewId) + const body = jsonApiDocument('reviews', { status: 'closed' }, String(argv.reviewId)) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'reviews', id: String(argv.reviewId) }, behavior: { destructive: true, sideEffects: ['Removes the pending review from the QA queue.'] } }) + }) + .demandCommand(1, 'Choose reviews list, get, request, update, or close.'), () => {}) +} diff --git a/src/commands/version-activations.ts b/src/commands/version-activations.ts new file mode 100644 index 0000000..41deb53 --- /dev/null +++ b/src/commands/version-activations.ts @@ -0,0 +1,19 @@ +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { versionActivationsDocumentation } from '../docs/resources' +import { listCapabilities } from '../list-capabilities' +import { getProjectGameId } from '../project' +import { gamePath, listResources, renderList, withDefaultGameOption, withListOptions } from './common' +import { registerResourceDiscovery } from './resource-docs' + +export function registerVersionActivationCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + + return yargs.command('version-activations', 'List stored public-version activations', activations => registerResourceDiscovery(activations, versionActivationsDocumentation) + .command('list', 'List stored activation events in chronological order', list => withDefaultGameOption(withListOptions(list, listCapabilities.versionActivations, 'version-activations'), projectGameId, 'Game whose version activations to list'), async argv => { + const path = gamePath(argv.game, 'version-activations') + renderList(await listResources(api, path, argv, listCapabilities.versionActivations), argv, 'version-activations') + }) + .demandCommand(1, 'Choose version-activations list, fields, or field.'), () => {}) +} diff --git a/src/commands/versions.ts b/src/commands/versions.ts new file mode 100644 index 0000000..70d1469 --- /dev/null +++ b/src/commands/versions.ts @@ -0,0 +1,543 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { basename, join, resolve } from 'path' +import type { Argv } from 'yargs' + +import { ApiClient } from '../api' +import { sanitizeDeveloperResourceAttribute } from '../developer-surface' +import { versionsDocumentation } from '../docs/resources' +import { CliError, inputError, registerInterruptCleanup, safeErrorCause } from '../errors' +import { characterCount, containsZeroWidthCharacter, requireChanges } from '../input' +import { jsonApiDocument, jsonValueKind, normalizeJsonApiResource, normalizeJsonApiResourceForRecovery, ResourceResult } from '../jsonapi' +import { listCapabilities } from '../list-capabilities' +import { isRecord } from '../json' +import { getProjectGameId, readProjectConfig } from '../project' +import { DEFAULT_DOWNLOAD_TIMEOUT_MS } from '../timeouts' +import { appendUploadFile } from '../uploads' +import { createZip } from '../zipfile' +import { AsyncCreateContract, createThenWait, pollArguments } from './async-create' +import { registerResourceDiscovery } from './resource-docs' +import { + gamePath, + getResource, + listResources, + mutationPreview, + MutationInputFields, + normalizeMutationResponse, + mutateResource, + pollUntil, + PollOutcome, + readExpectedResource, + render, + renderList, + renderMutation, + requireExpectedJsonApiResource, + responseLocation, + requestTimeout, + requireConfirmation, + resolveMutationInput, + withDataOption, + withDefaultGameOption, + withFormatOption, + withGameMutationOptions, + withListOptions, + withMutationOptions, + withOutputOptions, + withTimeoutOption, + withUploadOutputOptions, + withWaitMeta, + withWaitOptions, + writeDownload +} from './common' + +const versionFields = ['filename', 'label', 'notes'] as const +const updateInput: MutationInputFields = { flags: versionFields, fields: versionFields } +const downloadTypes = ['source', 'hosted'] as const +// A malformed known attribute deliberately collapses normalized output to +// identity-only. Both the activation preflight and `versions current` must +// therefore read the allocation from the validated raw resource: reporting an +// unreadable game as "no tracks" would state an allocation as fact and let an +// agent roll forward from an allocation that was never observed. +function readGameTrackAllocation ( + current: { raw: Record }, + description: string +): unknown[] { + const attributes = current.raw.attributes + const relationships = current.raw.relationships + const attributesAreObject = isRecord(attributes) + if (attributes !== undefined && !attributesAreObject) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response contained a malformed attributes member.`, 5, { + details: { expected_attributes_kind: 'object', received_attributes_kind: jsonValueKind(attributes) }, + retryable: false + }) + } + const hasTracks = attributesAreObject && Object.prototype.hasOwnProperty.call(attributes, 'tracks') + const relationshipsAreObject = isRecord(relationships) + if (relationships !== undefined && !relationshipsAreObject) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response contained a malformed relationships member.`, 5, { + details: { expected_relationships_kind: 'object', received_relationships_kind: jsonValueKind(relationships) }, + retryable: false + }) + } + const hasRelationshipTracks = relationshipsAreObject && Object.prototype.hasOwnProperty.call(relationships, 'tracks') + if (hasRelationshipTracks) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response placed tracks in the wrong JSON:API resource member.`, 5, { + details: { + expected_tracks_source: 'attributes', + received_tracks_source: 'relationships', + attributes_tracks_member: hasTracks ? 'present' : 'missing' + }, + retryable: false + }) + } + const rawTracks = hasTracks ? attributes.tracks : undefined + const sanitizedTracks = hasTracks + ? sanitizeDeveloperResourceAttribute('games', 'tracks', rawTracks) + : { valid: true, value: [] } + if (!sanitizedTracks.valid || !Array.isArray(sanitizedTracks.value)) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response did not contain a valid tracks array.`, 5, { + details: { + expected_tracks_kind: 'array', + received_attributes_kind: jsonValueKind(attributes), + tracks_member: hasTracks ? 'present' : 'missing', + ...(hasTracks ? { received_tracks_kind: jsonValueKind(rawTracks) } : {}) + }, + retryable: false + }) + } + return sanitizedTracks.value +} + +// Only `versions current` reports the public version. The activation preflight +// deliberately does not read it: widening that preflight would let an unrelated +// malformed attribute mask ACTIVE_VERSION_MULTIPLE_TRACKS. +function readGamePublicVersion ( + current: { raw: Record }, + description: string +): { value: unknown, present: boolean } { + const attributes = current.raw.attributes + const attributesAreObject = isRecord(attributes) + if (!attributesAreObject || !Object.prototype.hasOwnProperty.call(attributes, 'public_version')) { + return { value: undefined, present: false } + } + const rawPublicVersion = attributes.public_version + const sanitized = sanitizeDeveloperResourceAttribute('games', 'public_version', rawPublicVersion) + if (!sanitized.valid) { + throw new CliError('INVALID_API_RESPONSE', `The ${description} response contained a malformed public_version member.`, 5, { + details: { received_public_version_kind: jsonValueKind(rawPublicVersion) }, + retryable: false + }) + } + return { value: sanitized.value, present: sanitized.value !== undefined } +} + +function validateVersionText (data: Record): void { + for (const field of versionFields) { + const value = data[field] + if (value === undefined) continue + if (typeof value !== 'string') throw inputError(`${field} must be a string.`) + if (field !== 'filename' && containsZeroWidthCharacter(value)) throw inputError(`${field} must not contain zero-width characters.`) + } + if (typeof data.label === 'string' && characterCount(data.label) > 256) { + throw inputError('label must contain at most 256 characters.') + } +} + +function withVersionUpdateOptions (yargs: Argv, projectGameId: string | undefined): Argv { + return withGameMutationOptions(withDataOption(yargs, 'JSON or TOON object, @file, or - for stdin; mutually exclusive with filename, label, and notes'), projectGameId, 'Parent Poki for Developers game ID') + .option('filename', { describe: 'Uploaded archive filename shown by the API', type: 'string' }) + .option('label', { describe: 'Human-readable version label', type: 'string' }) + .option('notes', { describe: 'Version description or release notes', type: 'string' }) +} + +function mutationOutcomeIsUncertain (error: unknown): boolean { + if (!(error instanceof CliError) || error.status === undefined) return true + // Authenticated mutations never follow a redirect, so a 3xx leaves the PATCH + // outcome at the redirect target unobserved: as uncertain as a timeout. + const redirect = error.status >= 300 && error.status < 400 + return redirect || (error.status >= 200 && error.status < 300) || error.status === 408 || error.status === 429 || error.status >= 500 +} + +function activationRecovery ( + gameID: string, + previousTracks: unknown[], + requestedTracks: unknown[] +): Record { + const previous = previousTracks.length === 1 && isRecord(previousTracks[0]) ? previousTracks[0] : undefined + const canRestoreExactly = previous?.track === 'public' && typeof previous.version_id === 'string' && previous.version_id !== '' && previous.weight === 100 + return { + inspect_current_allocation: { + required_before_next_mutation: true, + command: 'poki', + arguments: ['versions', 'current', '--game', gameID, '--format', 'json'], + compare_with: { + previous_tracks: previousTracks, + requested_tracks: requestedTracks + } + }, + restore_previous_allocation: canRestoreExactly + ? { + available_via_cli: true, + condition: 'only_after_inspection_confirms_the_requested_allocation_is_active_and_rollback_is_desired', + command: 'poki', + arguments: ['versions', 'activate', String(previous.version_id), '--game', gameID, '--yes', '--format', 'json'] + } + : { + available_via_cli: false, + condition: 'manual_recovery_only_after_current_state_inspection', + reason: 'versions activate can restore exactly only a previous single public track at weight 100; preserve details.previous_tracks for coordinated recovery.' + } + } +} + +// Version processing states from the field reference; done and error are the +// only terminal ones. +function versionStateOf (resource: unknown): string { + const data = isRecord(resource) ? resource.data : undefined + const state = isRecord(data) ? data.state : undefined + return typeof state === 'string' ? state : 'unknown' +} + +async function waitForVersion (api: ApiClient, versionId: string, argv: Record): Promise { + return await pollUntil(argv, async timeoutMs => { + const resource = await getResource( + api, + `/versions/${encodeURIComponent(versionId)}`, + { ...argv, raw: false, timeoutMs }, + { type: 'game_versions', id: versionId }, + 'version poll' + ) + const state = versionStateOf(resource) + return { resource, state, terminal: state === 'done' || state === 'error', succeeded: state === 'done' } + // A version poll is an ordinary GET, so it uses the ordinary request + // timeout even when the upload itself used the multipart one. + }, `version ${versionId} processing`, requestTimeout(argv) ?? api.timeoutMs) +} + +// Version uploads predate the JSON:API resource surface and the deployed API +// still returns the created game version as a plain JSON object. Accept that +// shape while also accepting a future JSON:API document. In both cases the +// normalized result goes through the same reviewed game_versions allowlist. +function normalizeVersionUploadResponse ( + body: unknown, + status: number, + path: string, + onRecoverySnapshot?: (result: ResourceResult) => void +): ResourceResult { + const plain = isRecord(body) && !Object.prototype.hasOwnProperty.call(body, 'data') + if (!plain) return normalizeMutationResponse(body, status, 'POST', path, { type: 'game_versions' }, onRecoverySnapshot) + + const attributes = Object.fromEntries(Object.entries(body) + .filter(([field]) => field !== 'type' && field !== 'id')) + const document = { + data: { + type: body.type ?? 'game_versions', + ...(Object.prototype.hasOwnProperty.call(body, 'id') ? { id: body.id } : {}), + attributes + } + } + const normalized = normalizeJsonApiResource(document) + onRecoverySnapshot?.(normalizeJsonApiResourceForRecovery(document)) + return normalized +} + +function uploadedVersionId ( + normalized: ResourceResult, + status: number, + path: string, + expectedGameID: string +): string { + const resource = isRecord(normalized.data) ? normalized.data : undefined + const id = resource?.id + const validID = typeof id === 'string' && id.trim() !== '' + const validType = resource?.type === 'game_versions' + const validGame = typeof resource?.game_id === 'string' && resource.game_id === expectedGameID + if (validID && validType && validGame) return id + + throw new CliError('INVALID_API_RESPONSE', 'The successful upload response did not identify one game version belonging to the requested game.', 5, { + status, + details: { + method: 'POST', + path, + expected_resource_type: 'game_versions', + expected_id_kind: 'non_empty_string', + expected_game_identity: 'requested_game', + received_primary_data_kind: jsonValueKind(normalized.data), + received_resource_type_matches: validType, + received_id_kind: jsonValueKind(id), + received_id_usable: validID, + received_game_identity_matches: validGame + }, + retryable: false, + hint: 'The upload may already have committed. Inspect current version state and do not replay the upload blindly.' + }) +} + +const uploadWaitContract: AsyncCreateContract = { + errorCode: 'VERSION_UPLOAD_WAIT_FAILED', + noun: 'version', + missingId: { + message: 'The version upload succeeded, but its response did not reliably identify a version belonging to the requested game.', + hint: 'Do not upload the build again. Use details.recovery.inspect_created_version to list the game versions and identify the existing upload.' + }, + pollFailed: { + message: 'The version was created, but polling its processing state failed.', + hint: 'Do not upload the build again. Use details.recovery.resume_poll to continue polling the created version.' + }, + inspect: argv => ({ + action: 'list_existing_versions', + arguments: [ + 'versions', 'list', + '--game', String(argv.game), + '--archived', 'all', + '--sort', '-created_at', + '--fields', 'id,filename,label,state,created_at', + '--format', 'json' + ] + }), + resumePoll: (createdId, argv) => ({ + action: 'poll_existing_version', + arguments: [ + 'versions', 'get', createdId, '--wait', + ...pollArguments(argv), + '--format', 'json' + ] + }) +} + +export function registerVersionCommands (yargs: Argv, api: ApiClient): Argv { + const projectGameId = getProjectGameId() + const project = readProjectConfig() + + return yargs.command('versions', 'Inspect, upload, download, archive, and activate game versions', versions => registerResourceDiscovery(versions, versionsDocumentation) + .command('list', 'List versions belonging to one game with bounded pagination', list => withDefaultGameOption(withListOptions(list, listCapabilities.versions, 'versions'), projectGameId, 'Parent Poki for Developers game ID') + .option('archived', { + describe: 'Select active, archived, or all versions', + choices: ['active', 'archived', 'all'] as const, + default: 'active' + }), async argv => { + const archiveFilter: Array<[string, string]> = argv.archived === 'active' + ? [['archived_at', 'null']] + : argv.archived === 'archived' + ? [['archived_at', 'not:null']] + : [] + const result = await listResources(api, gamePath(argv.game, 'versions'), argv, listCapabilities.versions, archiveFilter) + renderList(result, argv, 'versions') + }) + .command('get ', 'Get a version by ID without needing its parent game ID', get => withWaitOptions(withOutputOptions(get), 'Poll until state reaches done; state error exits nonzero with the final resource') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }), async argv => { + if (argv.wait === true) { + const outcome = await waitForVersion(api, String(argv.versionId), argv) + render(withWaitMeta(outcome), argv) + return + } + const versionID = String(argv.versionId) + render(await getResource(api, `/versions/${encodeURIComponent(versionID)}`, argv, { type: 'game_versions', id: versionID }, 'version read'), argv) + }) + .command('files ', 'List the uploaded files recorded for a version', files => withDefaultGameOption(withListOptions(files, listCapabilities.versionFiles, 'version-files'), projectGameId, 'Parent game ID') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }), async argv => { + const path = gamePath(argv.game, 'versions', argv.versionId, 'files') + renderList(await listResources(api, path, argv, listCapabilities.versionFiles), argv, 'version-files') + }) + .command('update ', 'Update a version label, filename, or release notes', update => withVersionUpdateOptions(update, projectGameId) + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }), async argv => { + const data = await resolveMutationInput(argv, updateInput, () => Object.fromEntries(versionFields.filter(field => argv[field] !== undefined).map(field => [field, argv[field]]))) + requireChanges(data) + validateVersionText(data) + + const id = String(argv.versionId) + const body = jsonApiDocument('game_versions', data, id) + const path = gamePath(argv.game, 'versions', id) + await renderMutation(api, argv, { method: 'PATCH', path, body, expected: { type: 'game_versions', id }, behavior: { sideEffects: ['Updates version metadata and audit history.'] } }) + }) + .command('upload', 'Zip and upload a build as a new version', upload => withWaitOptions(withDefaultGameOption(withMutationOptions(withUploadOutputOptions(upload)), projectGameId, 'Game that will own the new version'), 'After upload, poll until state reaches done; state error exits nonzero with the final resource') + .option('build-dir', { + describe: 'Non-empty existing directory to zip; defaults to build_dir from project config or dist', + default: project.build_dir ?? 'dist', + type: 'string' + }) + .option('label', { describe: 'Human-readable version label', type: 'string' }) + .option('notes', { describe: 'Version notes', type: 'string' }) + .option('disable-image-compression', { describe: 'Disable image compression for this version', type: 'boolean', default: false }) + .option('disable-transforms', { describe: 'Disable upload transforms for this version', type: 'boolean', default: false }), async argv => { + const requestedBuildDir = String(argv.buildDir) + if (requestedBuildDir.trim() === '') { + throw inputError('--build-dir must be a non-empty directory path.', { build_dir: requestedBuildDir }) + } + const buildDir = resolve(requestedBuildDir) + if (!existsSync(buildDir)) throw inputError(`Build directory '${buildDir}' does not exist.`, { build_dir: buildDir }) + if (!statSync(buildDir).isDirectory()) throw inputError(`Build directory '${buildDir}' is not a directory.`, { build_dir: buildDir }) + if (readdirSync(buildDir).length === 0) throw inputError(`Build directory '${buildDir}' is empty.`, { build_dir: buildDir }) + validateVersionText({ label: argv.label, notes: argv.notes }) + const path = gamePath(argv.game, 'versions') + const previewBody = { + multipart: true, + file: { source_directory: buildDir, archive_name: 'build.zip' }, + ...(argv.label === undefined ? {} : { label: argv.label }), + ...(argv.notes === undefined ? {} : { notes: argv.notes }), + disable_image_compression: argv.disableImageCompression, + disable_transforms: argv.disableTransforms + } + if (mutationPreview('POST', path, previewBody, argv, { + sideEffects: ['Creates a version and starts asynchronous upload processing.'] + })) return + + const temporaryDirectory = mkdtempSync(join(tmpdir(), 'poki-cli-upload-')) + // An interrupt never reaches the finally below, which would leak the + // build archive in the OS temporary directory. + const removeTemporaryOnInterrupt = registerInterruptCleanup(() => { + rmSync(temporaryDirectory, { recursive: true, force: true }) + }) + const archivePath = join(temporaryDirectory, 'build.zip') + try { + await createZip(archivePath, buildDir) + const form = new FormData() + await appendUploadFile(form, 'file', archivePath, 'build.zip', 'application/zip') + if (argv.label !== undefined) form.append('label', String(argv.label)) + if (argv.notes !== undefined) form.append('notes', String(argv.notes)) + if (argv.disableImageCompression) form.append('disable-image-compression', 'true') + if (argv.disableTransforms) form.append('disable-transforms', 'true') + const belongsToGame = (data: unknown, requireType: boolean): Record | undefined => { + if (!isRecord(data) || data.game_id !== String(argv.game)) return undefined + return !requireType || data.type === 'game_versions' ? data : undefined + } + await createThenWait({ + contract: uploadWaitContract, + argv, + send: async () => await api.request({ method: 'POST', path, rawBody: form, timeoutMs: requestTimeout(argv) }), + normalize: (response, onRecoverySnapshot) => normalizeVersionUploadResponse(response.body, response.status, path, onRecoverySnapshot), + createdIdOf: (normalized, response) => uploadedVersionId(normalized, response.status, path, String(argv.game)), + requireCreatedId: 'always', + recoveryFromSnapshot: data => belongsToGame(data, true), + recoveryFromNormalized: data => belongsToGame(data, false), + poll: async createdId => await waitForVersion(api, createdId, argv) + }) + } finally { + removeTemporaryOnInterrupt() + // Cleanup must never replace an upload result or a no-replay recovery + // error after the remote mutation may already have committed. + try { + rmSync(temporaryDirectory, { recursive: true, force: true }) + } catch {} + } + }) + .command('archive ', 'Archive a version', archive => withGameMutationOptions(archive, projectGameId, 'Parent Poki for Developers game ID') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }), async argv => { + const id = String(argv.versionId) + await renderMutation(api, argv, { method: 'POST', path: gamePath(argv.game, 'versions', id, '_archive'), expected: { type: 'game_versions', id }, behavior: { sideEffects: ['Hides the version from active lists; reversible via versions unarchive.'] }, action: { result: { type: 'game_versions', id, action: 'archived' }, preferResponse: true } }) + }) + .command('unarchive ', 'Restore an archived version', unarchive => withGameMutationOptions(unarchive, projectGameId, 'Parent Poki for Developers game ID') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }), async argv => { + const id = String(argv.versionId) + await renderMutation(api, argv, { method: 'POST', path: gamePath(argv.game, 'versions', id, '_unarchive'), expected: { type: 'game_versions', id }, behavior: { sideEffects: ['Returns the version to active lists.'] }, action: { result: { type: 'game_versions', id, action: 'unarchived' }, preferResponse: true } }) + }) + .command('activate ', 'Send all public traffic to a backend-eligible version', activate => withGameMutationOptions(activate, projectGameId, 'Parent Poki for Developers game ID', { destructive: true }) + .positional('version-id', { describe: 'Version ID; the backend requires state done and, depending on permissions, an approved review', type: 'string', demandOption: true }), async argv => { + requireConfirmation(argv, 'Activating a version') + const gameID = String(argv.game) + const path = gamePath(gameID) + const publicTrack = { track: 'public', version_id: String(argv.versionId), weight: 100 } + if (mutationPreview('PATCH', path, jsonApiDocument('games', { tracks: [publicTrack] }, gameID), argv, { + destructive: true, + nonAtomic: true, + sideEffects: [ + 'Replaces the public traffic allocation with this version at 100%.', + 'Execution first fetches the game and rejects activation when multiple tracks currently exist; this offline preview cannot validate that state.' + ] + })) return + const current = await readExpectedResource(api, path, argv, { type: 'games', id: gameID }, 'game preflight') + const previousTracks = readGameTrackAllocation(current, 'game preflight') + if (previousTracks.length > 1) { + throw new CliError('ACTIVE_VERSION_MULTIPLE_TRACKS', 'The active version cannot be changed while multiple tracks exist.', 4, { + status: 409, + details: { game_id: gameID, track_count: previousTracks.length, tracks: previousTracks }, + hint: 'Resolve the game\'s traffic experiment or track allocation before activating a version.' + }) + } + const body = jsonApiDocument('games', { tracks: [publicTrack] }, gameID) + let result: unknown + try { + result = await mutateResource(api, 'PATCH', path, body, argv, { type: 'games', id: gameID }) + } catch (error) { + if (!mutationOutcomeIsUncertain(error)) throw error + const original = error instanceof CliError ? error : undefined + const requestedTracks = [publicTrack] + throw new CliError('VERSION_ACTIVATION_OUTCOME_UNKNOWN', 'The activation PATCH may have committed, so the current traffic allocation is unknown.', original?.exitCode ?? 5, { + status: original?.status, + retryable: false, + requestId: original?.requestId, + retryAfter: original?.retryAfter, + hint: `Run \`poki versions current --game ${gameID} --format json\` before any retry or rollback. Do not replay the activation blindly.`, + details: { + activation_state: 'unknown', + game_id: gameID, + requested_version_id: String(argv.versionId), + previous_tracks: previousTracks, + requested_tracks: requestedTracks, + recovery: activationRecovery(gameID, previousTracks, requestedTracks), + cause: safeErrorCause(error) + } + }) + } + // previous_tracks lets an agent roll back by re-activating the version + // that held public traffic before this change. + if (argv.raw !== true && isRecord(result)) { + const meta = result.meta + result.meta = { ...(isRecord(meta) ? meta : {}), previous_tracks: previousTracks } + } + render(result, argv) + }) + .command('current', 'Show which version holds public traffic and the full track allocation', current => withDefaultGameOption(withOutputOptions(current), projectGameId, 'Game to inspect'), async argv => { + const gameID = String(argv.game) + const response = await api.request({ path: gamePath(gameID), timeoutMs: requestTimeout(argv) }) + if (argv.raw === true) { + render(response.body, argv) + return + } + const current = requireExpectedJsonApiResource(response.body, { type: 'games', id: gameID }, 'current game read') + const tracks = readGameTrackAllocation(current, 'current game read') + const publicVersion = readGamePublicVersion(current, 'current game read') + render({ + data: { + game_id: current.raw.id, + ...(publicVersion.present ? { public_version: publicVersion.value } : {}), + tracks + }, + meta: {} + }, argv) + }) + .command('download ', 'Download a source or hosted version archive to disk', download => withDefaultGameOption(withTimeoutOption(withFormatOption(download), DEFAULT_DOWNLOAD_TIMEOUT_MS), projectGameId, 'Parent game ID') + .positional('version-id', { describe: 'Version ID', type: 'string', demandOption: true }) + .option('type', { describe: 'Archive kind', choices: downloadTypes, default: 'source' }) + .option('output', { describe: 'Destination file path', type: 'string' }) + .option('force', { describe: 'Replace an existing destination after the complete download is ready', type: 'boolean', default: false }), async argv => { + const requestedOutput = String(argv.output ?? `${String(argv.versionId)}-${String(argv.type)}.zip`) + // An explicit empty --output would otherwise resolve to the current + // working directory and waste the whole transfer on a path that can + // never be published. + if (requestedOutput.trim() === '') { + throw inputError('--output must be a non-empty file path.', { output: requestedOutput }) + } + const destination = resolve(requestedOutput) + const existing = statSync(destination, { throwIfNoEntry: false }) + if (existing !== undefined && !existing.isFile()) { + throw inputError(`Destination '${destination}' is not a regular file.`, { output: destination }) + } + if (existing !== undefined && !argv.force) { + throw inputError(`Destination '${destination}' already exists. Pass --force to replace it.`, { output: destination }) + } + const path = gamePath(argv.game, 'download', argv.versionId, argv.type) + const locationResponse = await api.request({ path, timeoutMs: requestTimeout(argv) }) + const location = api.resolveExternalLocation(responseLocation(locationResponse.body, 'a version download location')) + // Without an explicit --timeout-ms the signed archive transfer falls back + // to the download budget, while the small location request above keeps + // the ordinary one. + const response = await api.downloadExternal( + location, + async body => await writeDownload(destination, body, Boolean(argv.force)), + requestTimeout(argv) + ) + render({ data: { version_id: argv.versionId, type: argv.type, path: destination, filename: basename(destination), bytes: response.body }, meta: {} }, argv) + }) + .demandCommand(1, 'Choose versions list, get, files, update, upload, archive, unarchive, activate, current, or download.'), () => {}) +} diff --git a/src/config.ts b/src/config.ts index f2fe40d..034b464 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,16 +1,37 @@ -import { homedir } from 'os' -import { join } from 'path' +import { homedir, userInfo } from 'os' +import { isAbsolute, join } from 'path' -export function getConfigDir (): string { - const defaultConfigDir = join(homedir(), '.config', 'poki') +import { serviceEnvironment } from './service-environment' +function absoluteDirectory (directory: string | undefined): string | undefined { + if (directory === undefined || directory.trim() === '' || !isAbsolute(directory)) return undefined + return directory +} + +function absoluteHomeDirectory (): string { + const home = absoluteDirectory(homedir()) + if (home !== undefined) return home + + const accountHome = absoluteDirectory(userInfo().homedir) + if (accountHome !== undefined) return accountHome + + throw new Error('Could not determine an absolute home directory for Poki credentials.') +} + +export function getConfigDir (environment: NodeJS.ProcessEnv = process.env): string { + let directory: string if (process.platform === 'win32') { - return process.env.LOCALAPPDATA !== undefined ? join(process.env.LOCALAPPDATA, 'Poki') : defaultConfigDir - } else if (process.env.XDG_CONFIG_HOME !== undefined) { - return join(process.env.XDG_CONFIG_HOME, 'poki') + const localAppData = absoluteDirectory(environment.LOCALAPPDATA) + directory = localAppData === undefined + ? join(absoluteHomeDirectory(), '.config', 'poki') + : join(localAppData, 'Poki') } else { - return defaultConfigDir + const xdgConfigHome = absoluteDirectory(environment.XDG_CONFIG_HOME) + directory = join(xdgConfigHome ?? join(absoluteHomeDirectory(), '.config'), 'poki') } + + const scope = serviceEnvironment(environment).configScope + return scope === undefined ? directory : join(directory, scope) } export interface Config { diff --git a/src/data/catalog.ts b/src/data/catalog.ts new file mode 100644 index 0000000..f243077 --- /dev/null +++ b/src/data/catalog.ts @@ -0,0 +1,470 @@ +import { resolvedSelectOutputName, visitSelectExpression } from './select-expression' +import { isRecord } from '../json' + +export interface ColumnDefinition { + name: string + type: string + description: string +} + +export interface TableDefinition { + name: string + description: string + grain: string + population: string + top_level: boolean + join_on?: string + columns: ColumnDefinition[] +} + +const c = (name: string, type: string, description: string): ColumnDefinition => ({ + name, + type, + description +}) + +const standardDimensions = (): ColumnDefinition[] => [ + c('date', 'Date', 'Europe/Amsterdam calendar date on which the metrics were recorded.'), + c('device_category', 'Nullable(String)', 'Audience device category derived from the user agent: desktop, mobile, or tablet; null means unavailable.'), + c('country_id', 'Nullable(String)', 'Two-letter audience country code; null means the country was unavailable.'), + c('context', 'String', 'Runtime context in which the game ran: playground is the Poki for Developers playtesting environment that the bundled recipes filter on, preview is the pre-release preview context, and other values may exist.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('p4d_game_version_id', 'Nullable(String)', 'Poki for Developers version ID observed for the metric; null means it could not be attributed.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.') +] + +const earnings = (): ColumnDefinition[] => [ + c('developer_earnings_eur', 'Float64', 'Attributed developer earnings in euros.'), + c('developer_earnings_usd', 'Float64', 'Attributed developer earnings converted to US dollars using the reporting-date rate.') +] + +const eventDimensions = (): ColumnDefinition[] => [ + c('date', 'Date', 'Europe/Amsterdam calendar date on which the game events occurred.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID that emitted the event.'), + c('p4d_version_id', 'Nullable(String)', 'Poki for Developers version ID that emitted the event; null means it could not be attributed.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('category', 'String', 'First measure() argument: the broad event group, such as level, tutorial, button, or difficulty.'), + c('action', 'String', 'Legacy column name for the second measure() argument named what in the SDK: the specific level, feature, or item.'), + c('label', 'String', 'Third measure() argument named action in the SDK for custom actions; empty for start, complete, fail, visible, and interact because those lifecycle values are represented by counters.'), + c('user_new', 'UInt8', '1 when the event came from a user considered new to the game, otherwise 0.'), + c('device_category', 'String', 'Audience device category recorded for the event: desktop, mobile, or tablet.') +] + +const adEarnings = (prefix: string, label: string): ColumnDefinition[] => [ + c(`${prefix}_developer_earnings_eur`, 'Float64', `Developer earnings in euros attributed to ${label}.`), + c(`${prefix}_developer_earnings_usd`, 'Float64', `Developer earnings in US dollars attributed to ${label}.`) +] + +const quickStatsEarnings = (period: string, description: string): ColumnDefinition[] => [ + c(`${period}_developer_earnings_eur`, 'Float64', `Developer earnings in euros for ${description}.`), + c(`${period}_developer_earnings_usd`, 'Float64', `Developer earnings in US dollars for ${description}.`) +] + +export const tableCatalog: TableDefinition[] = [ + { + name: 'dbt_p4d_gameplays', + description: 'Daily gameplay counts by game, version, and audience dimensions. Gameplay boundaries come from Poki SDK gameplay events. Optional public SDK background: https://sdk.poki.com/sdk-documentation.', + grain: 'One row per date, game, version, device category, country, context, and team.', + population: 'Gameplay sessions observed through Poki SDK gameplay events.', + top_level: true, + columns: [ + ...standardDimensions(), + c('gameplays', 'UInt64', 'Number of gameplay sessions in the dimension row.') + ] + }, + { + name: 'dbt_p4d_users', + description: 'Daily distinct-user counts for visiting, loading, and playing a game. Loading and gameplay semantics are described by the bundled columns and metrics. Optional public SDK background: https://sdk.poki.com/sdk-documentation.', + grain: 'One row per date, game, version, device category, country, context, and team.', + population: 'Distinct daily users observed on a game page, with playing and loading subsets.', + top_level: true, + columns: [ + ...standardDimensions(), + c('daily_active_users', 'UInt64', 'Distinct users who were active on the game page during the date.'), + c('daily_playing_users', 'UInt64', 'Distinct active users who reached gameplay during the date.'), + c('daily_not_playing_users', 'UInt64', 'Active users who did not reach gameplay; calculated as daily_active_users minus daily_playing_users.'), + c('daily_loading_users', 'UInt64', 'Distinct users observed in the loading flow.'), + c('daily_finished_loading_users', 'UInt64', 'Distinct users for whom game loading finished was observed.') + ] + }, + { + name: 'dbt_p4d_engagement_per_gameplay', + description: 'Gameplay-based engagement totals from completed gameplay records, excluding detected outliers. Raw time fields are milliseconds; divide by 1000 for seconds. Bundled engagement recipes perform that conversion. Optional public SDK background: https://sdk.poki.com/sdk-documentation.', + grain: 'One row per date, game, version, device category, country, context, and team.', + population: 'Completed gameplay records after detected engagement outliers are excluded.', + top_level: true, + columns: [ + ...standardDimensions(), + c('gameplays', 'UInt64', 'Number of non-outlier gameplay records represented by the row.'), + c('video_ad_visible_time', 'Float64', 'Total milliseconds a video advertisement was visible during represented gameplays; divide by 1000 for seconds.'), + c('play_time', 'Float64', 'Total milliseconds spent in active gameplay; divide by 1000 for seconds.'), + c('pre_play_time', 'Float64', 'Total milliseconds between page arrival and the first gameplay start; divide by 1000 for seconds.') + ] + }, + { + name: 'dbt_p4d_netlib_overview', + description: 'Hourly Netlib lobby and connection event totals for developer-owned games.', + grain: 'One row per Europe/Amsterdam local hour, game, and team.', + population: 'Netlib analytics events attributed to Poki for Developers games.', + top_level: true, + columns: [ + c('hour', 'DateTime', 'Europe/Amsterdam local hour in which the Netlib events occurred; this follows CET/CEST daylight saving.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('lobbies_created', 'UInt64', 'Number of lobby-created events during the hour.'), + c('lobbies_joined', 'UInt64', 'Number of lobby-joined events during the hour.'), + c('lobbies_updated', 'UInt64', 'Number of lobby-updated events during the hour.'), + c('client_connected', 'UInt64', 'Number of client-connected events during the hour.'), + c('peer_connections', 'UInt64', 'Distinct connected peer identifiers observed during the hour. A peer-to-peer connection is represented by both peers, so divide the summed value by 2 for connected peer pairs.') + ] + }, + { + name: 'dbt_p4d_monetization', + description: 'Daily audience, playtime, ad-impression, and per-format developer-earnings totals. Ad field semantics are bundled with the table. Optional public SDK background: https://sdk.poki.com/sdk-documentation.', + grain: 'One row per date, game, device category, country, context, and team.', + population: 'Daily users, playtime, ad impressions, and attributed earnings in the monetization model.', + top_level: true, + columns: [ + ...standardDimensions().filter(column => column.name !== 'p4d_game_version_id'), + c('platform_display_impressions', 'UInt64', 'Revenue-share-eligible display-ad impressions served by the Poki platform outside the game canvas.'), + c('preroll_video_impressions', 'UInt64', 'Video-ad impressions shown before gameplay.'), + c('gamebar_display_impressions', 'UInt64', 'Display-ad impressions served in the Poki game bar.'), + c('ingame_display_impressions', 'UInt64', 'Display-ad impressions served inside the game experience.'), + c('midroll_video_impressions', 'UInt64', 'Commercial-break video impressions shown at natural gameplay interruptions.'), + c('rewarded_video_impressions', 'UInt64', 'Rewarded video impressions initiated by player choice.'), + c('daily_active_users', 'UInt64', 'Distinct users active on the game page during the date.'), + c('daily_playing_users', 'UInt64', 'Distinct active users who reached gameplay during the date.'), + c('total_play_time', 'Float64', 'Total active gameplay time in seconds.'), + ...adEarnings('ingame_display', 'in-game display advertisements'), + ...adEarnings('gamebar_display', 'game-bar display advertisements'), + ...adEarnings('platform_display', 'platform display advertisements'), + ...adEarnings('preroll_video', 'pre-roll video advertisements'), + ...adEarnings('midroll_video', 'mid-roll commercial-break advertisements'), + ...adEarnings('rewarded_video', 'rewarded video advertisements') + ] + }, + { + name: 'dbt_p4d_developer_earnings', + description: 'Daily developer earnings by game and audience dimensions, including the server-calculated shared portion.', + grain: 'One row per date, game, device category, country, context, and team.', + population: 'Developer earnings attributed to the represented game and audience dimensions.', + top_level: true, + columns: [ + ...standardDimensions().filter(column => column.name !== 'p4d_game_version_id'), + ...earnings(), + c('developer_earnings_shared_eur', 'Float64', 'Server-calculated shared developer earnings in euros.'), + c('developer_earnings_shared_usd', 'Float64', 'Server-calculated shared developer earnings in US dollars.') + ] + }, + { + name: 'dbt_p4d_game_errors_per_gameplay', + description: 'Hourly JavaScript error occurrences grouped by gameplay and execution environment. Each row identifies one error within one gameplay.', + grain: 'One row per hour, game, version, environment, error fingerprint, and gameplay.', + population: 'Gameplays that emitted a captured JavaScript error; rows are error/gameplay combinations.', + top_level: true, + columns: [ + c('date_hour', 'DateTime', 'Europe/Amsterdam local hour in which the error occurred; this follows CET/CEST daylight saving.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('p4d_game_version_id', 'String', 'Poki for Developers version ID that emitted the error.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('engine', 'String', 'Game engine recorded for the game.'), + c('engine_version', 'String', 'Game engine version reported with the error.'), + c('browser_name', 'String', 'Parsed browser name.'), + c('browser_version', 'String', 'Parsed browser version.'), + c('device_category', 'String', 'Recorded device category: desktop, mobile, or tablet.'), + c('error_name', 'String', 'JavaScript error name or class.'), + c('error_message', 'String', 'Normalized error message with skipped-error throttling suffixes removed.'), + c('sum_skipped', 'UInt64', 'Number of additional occurrences reported as skipped by server-side error throttling.'), + c('error_id', 'String', 'Stable error fingerprint used to group equivalent errors.'), + c('error_stack', 'String', 'Most common stack trace for the grouped row.'), + c('stack_line', 'String', 'Most common primary stack line for the grouped row.'), + c('errors', 'UInt64', 'Observed occurrence count for this error in this gameplay row.'), + c('gameplay_id', 'UInt64', 'Numeric gameplay identifier parsed from the error report\'s user identifier.'), + c('same_engine_games', 'UInt64', 'Number of other games using the same engine that emitted the same error name and message in the modeled period.') + ] + }, + { + name: 'dbt_p4d_game_errors_gameplays', + description: 'Hourly gameplay totals by game version and environment, intended as denominators for error-impact calculations.', + grain: 'One row per hour, game, version, engine, browser, device category, and team.', + population: 'Gameplay sessions represented by the error-impact denominator model.', + top_level: true, + columns: [ + c('date_hour', 'DateTime', 'Europe/Amsterdam local hour containing the gameplay sessions; this follows CET/CEST daylight saving.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('p4d_game_version_id', 'String', 'Poki for Developers version ID.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('engine', 'String', 'Game engine recorded for the game.'), + c('browser_name', 'String', 'Parsed browser name.'), + c('browser_version', 'String', 'Parsed browser version.'), + c('device_category', 'String', 'Recorded device category: desktop, mobile, or tablet.'), + c('gameplays', 'UInt64', 'Distinct gameplay sessions in the environment row.') + ] + }, + { + name: 'dbt_p4d_game_new_high_impact_errors', + description: 'Errors not seen for the same game in the prior seven days that affect at least 10% of at least 500 daily gameplays.', + grain: 'One row per date, game, and new high-impact error fingerprint.', + population: 'Newly observed errors meeting the modeled daily gameplay-count and impact thresholds.', + top_level: true, + columns: [ + c('date', 'Date', 'Europe/Amsterdam calendar date on which the new high-impact error was detected.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('error_id', 'String', 'Stable error fingerprint.'), + c('error_name', 'String', 'Representative JavaScript error name or class.'), + c('error_message', 'String', 'Representative normalized error message.'), + c('error_stack', 'String', 'Most common stack trace for the error.'), + c('stack_line', 'String', 'Most common primary stack line for the error.'), + c('affected_gameplays', 'UInt64', 'Distinct gameplays in which the error occurred.'), + c('total_gameplays', 'UInt64', 'Total modeled gameplays for the game on the date.'), + c('gameplay_percentage', 'Float64', 'Approximate affected_gameplays divided by total_gameplays, capped at 1.0.') + ] + }, + { + name: 'dbt_p4d_game_events_v2', + description: 'Aggregated custom measure() events by game, version, audience, and event key. Rows are aggregates, not raw events. Naming semantics are bundled with the columns. Optional public integration guide: https://sdk.poki.com/game-events.', + grain: 'One row per date, game, version, audience dimensions, and normalized event key.', + population: 'Gameplays containing or emitting the represented custom event; one gameplay can contribute to many event rows.', + top_level: true, + columns: [ + ...eventDimensions(), + c('gameplays', 'UInt64', 'Number of gameplays containing this category/what/action combination.'), + c('starts', 'UInt64', 'Gameplays containing at least one start event for this category and what value.'), + c('completes', 'UInt64', 'Gameplays containing at least one complete event for this category and what value.'), + c('fails', 'UInt64', 'Gameplays containing at least one fail event for this category and what value.'), + c('seen', 'UInt64', 'Gameplays containing at least one visible event for this category and what value.'), + c('interacted', 'UInt64', 'Gameplays containing at least one interact event for this category and what value.'), + c('lefts', 'UInt64', 'Gameplays whose final event overall was a start for this category and what value, indicating no later event was observed.'), + c('total_starts', 'UInt64', 'Total start event occurrences, including repeated starts within one gameplay.'), + c('total_completes', 'UInt64', 'Total complete event occurrences, including repeats within one gameplay.'), + c('total_fails', 'UInt64', 'Total fail event occurrences, including repeats within one gameplay.'), + c('total_seen', 'UInt64', 'Total visible event occurrences, including repeats within one gameplay.'), + c('total_interacted', 'UInt64', 'Total interact event occurrences, including repeats within one gameplay.'), + c('total_events', 'UInt64', 'Total occurrences of all action values for this category/what/action grouping.') + ] + }, + { + name: 'dbt_p4d_game_events_times_v2', + description: 'Dynamic 1-, 5-, or 10-second buckets for custom-event arrival and start-to-outcome timing below one hour. Timing semantics are bundled with the columns. Optional public integration guide: https://sdk.poki.com/game-events.', + grain: 'One row per date, game, version, audience dimensions, event key, timing type, and time bucket.', + population: 'Gameplays with a selected custom-event timing; one gameplay can contribute to multiple event/timing groups.', + top_level: true, + columns: [ + ...eventDimensions(), + c('time_type', 'String', 'Timing being bucketed: event for first arrival, complete or fail since the preceding start, or interact since the preceding visible event.'), + c('time_bucket', 'UInt64', 'Inclusive lower bound of the bucket in seconds; modeled values are non-negative and below 3600.'), + c('time_granularity', 'UInt64', 'Bucket width in seconds: 1, 5, or 10, selected to keep the distribution below roughly 100 buckets.'), + c('gameplays', 'UInt64', 'Number of gameplays whose selected timing falls in this bucket.') + ] + }, + { + name: 'dbt_p4d_game_events_funnel_v2', + description: 'Ordered custom-event prefixes for funnel traversal. The model keeps at most the first 200 events per gameplay and omits a date/game with more than 5000 distinct event keys. Funnel hashes are signed 64-bit identifiers that may exceed JavaScript safe-integer precision: select event_hash through toString, and use prefix_hashes only as a has_any_int64 filter with exact decimal strings. Funnel semantics are bundled with the columns. Optional public integration guide: https://sdk.poki.com/game-events.', + grain: 'One row per date, game, version, audience dimensions, event, and ordered prefix position/hash.', + population: 'Sampled eligible gameplay event sequences; one gameplay contributes to multiple retained prefix rows.', + top_level: true, + columns: [ + c('date', 'Date', 'Europe/Amsterdam calendar date on which the event sequence occurred.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('p4d_version_id', 'String', 'Poki for Developers version ID that emitted the sequence.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('user_new', 'UInt8', '1 when the sequence came from a user considered new to the game, otherwise 0.'), + c('device_category', 'String', 'Audience device category recorded for the sequence: desktop, mobile, or tablet.'), + c('prefix_len', 'UInt64', 'Zero-based position of this event in the retained gameplay sequence.'), + c('event', 'String', "Canonical funnel event key encoded as category^what^action, with '^' reserved as the separator. Special action values are lowercased. Pass this value verbatim to game-event-funnels --event."), + c('event_hash', 'Int64', 'Signed 64-bit hash of the ordered sequence prefix ending at this event. It may exceed JavaScript safe-integer precision. Select one hash with {alias: "event_hash", function: {name: "toString", args: [{field: "event_hash"}]}}; for distinct hashes, aggregate the same toString function with groupUniqArray. The CLI rejects direct numeric output and numeric comparisons. Never consume this identifier as a JavaScript number.'), + c('prefix_hashes', 'Array(Int64)', 'Eligible prior-prefix hashes used for skip-tolerant traversal, bounded to the recent lookback window. This is filter-only in the CLI: use ["prefix_hashes", "has_any_int64", ["-8340446448795919230"]] with exact base-10 decimal strings, never JavaScript numbers. Direct output, scalar has, and other operators are rejected.'), + c('gameplay_sample_percentage', 'Float64', 'Percentage of eligible gameplay sequences represented by the row; currently emitted as 100.'), + c('gameplays', 'UInt64', 'Number of gameplays represented by this event and prefix combination.') + ] + }, + { + name: 'dbt_p4d_player_feedback', + description: 'Player feedback joined with available browser, device, gameplay, error, and screenshot diagnostics.', + grain: 'One row per player feedback submission.', + population: 'Submitted thumbs-up, thumbs-down, and bug-report feedback with available diagnostics.', + top_level: true, + columns: [ + c('timestamp', 'DateTime', 'Europe/Amsterdam local timestamp at which the feedback was submitted; this follows CET/CEST daylight saving.'), + c('type', 'String', 'Feedback kind: thumbs_up, thumbs_down, or bugreport; this value set is enforced at ingestion.'), + c('message', 'String', 'Original player feedback message.'), + c('english_message', 'String', 'English translation when one was captured; otherwise an empty string.'), + c('screenshot_url', 'String', 'URL of an attached screenshot when available.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('country', 'String', 'Human-readable country name.'), + c('country_code', 'String', 'Uppercase two-letter country code.'), + c('browser_name', 'Nullable(String)', 'Parsed browser name; null for feedback sources without diagnostics.'), + c('browser_version', 'Nullable(String)', 'Parsed browser version; null when unavailable.'), + c('os_name', 'Nullable(String)', 'Parsed operating-system name; null when unavailable.'), + c('os_version', 'Nullable(String)', 'Parsed operating-system version; null when unavailable.'), + c('device_category', 'Nullable(String)', 'Recorded device category: desktop, mobile, or tablet; null when unavailable.'), + c('p4d_version_id', 'Nullable(String)', 'Poki for Developers version ID active for the feedback; null when unavailable.'), + c('has_adblock', 'Nullable(Bool)', 'Whether ad blocking was detected; null when unavailable.'), + c('game_resolution', 'Nullable(String)', 'Reported game viewport resolution; null when unavailable.'), + c('was_fullscreen_this_gameplay', 'Nullable(Bool)', 'Whether fullscreen was used during the gameplay; null when unavailable.'), + c('loading_finished', 'Nullable(Bool)', 'Whether game loading finished before feedback; null when unavailable.'), + c('gametime_seconds', 'Nullable(Float64)', 'Rounded seconds spent on the game page before feedback; null when unavailable.'), + c('errors', 'Nullable(String)', 'JSON-encoded array of captured errors; null when no errors or diagnostics were supplied.'), + c('webgl_renderer', 'Nullable(String)', 'Reported WebGL renderer or GPU description; null when unavailable.'), + c('device_pixel_ratio', 'Nullable(Float64)', 'Browser device-pixel ratio; null when unavailable.'), + c('probably_spammy', 'Nullable(Bool)', 'Model-derived indication that the feedback message is probably spam.') + ] + }, + { + name: 'dbt_p4d_games_overview', + description: 'Daily game-level release, audience, engagement, monetization, and earnings overview by country and device.', + grain: 'One row per date, game, device category, and country.', + population: 'Daily game traffic and attributed engagement, monetization, and earnings represented by the overview model.', + top_level: true, + columns: [ + c('date', 'Date', 'Europe/Amsterdam metric date.'), + c('release_date', 'Date', 'Europe/Amsterdam calendar date on which the current release phase began.'), + c('release_status', 'String', 'Game release status on the metric date: one of not-released, no-link-release, technical-test, soft-release, limited-release, full-release, or 10k-test, each also occurring with a -with-content-restrictions suffix.'), + c('release_status_changed_at', 'DateTime', 'Europe/Amsterdam local timestamp at which the release status last changed; this follows CET/CEST daylight saving.'), + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('team_id', 'String', 'ID of the Poki for Developers team that owns the game.'), + c('device_category', 'String', 'Audience device category: desktop, mobile, or tablet.'), + c('country_id', 'String', 'Two-letter audience country code.'), + c('num_domains_live', 'UInt64', 'Number of distinct sites or domains on which the game was live.'), + c('gameplays', 'UInt64', 'Number of gameplay sessions.'), + ...earnings(), + c('daily_active_users', 'UInt64', 'Distinct users active on the game page.'), + c('daily_playing_users', 'UInt64', 'Distinct active users who reached gameplay.'), + c('play_time', 'Float64', 'Total active gameplay time in seconds.'), + c('pre_play_time', 'Float64', 'Total seconds between page arrival and first gameplay.'), + c('video_ad_visible_time', 'Float64', 'Total seconds video advertisements were visible.'), + c('ingame_display_impressions', 'UInt64', 'In-game display-ad impression count.'), + c('gamebar_display_impressions', 'UInt64', 'Game-bar display-ad impression count.'), + c('platform_display_impressions', 'UInt64', 'Platform display-ad impression count.'), + c('preroll_video_impressions', 'UInt64', 'Pre-roll video-ad impression count.'), + c('midroll_video_impressions', 'UInt64', 'Mid-roll commercial-break impression count.'), + c('rewarded_video_impressions', 'UInt64', 'Rewarded video-ad impression count.'), + c('video_ad_visible_time_dpu', 'Float64', 'Total video-ad-visible seconds attributed to DPU (daily playing users); divide by daily_playing_users for a per-playing-user value.'), + c('play_time_dpu', 'Float64', 'Total active gameplay seconds attributed to DPU; divide by daily_playing_users for a per-playing-user value.'), + c('pre_play_time_dpu', 'Float64', 'Total pre-play seconds attributed to DPU; divide by daily_playing_users for a per-playing-user value.') + ] + }, + { + name: 'dbt_p4d_quick_stats', + description: 'Precomputed team developer-earnings totals for common reporting windows. Rolling windows end yesterday; all-time also excludes today.', + grain: 'One row per team.', + population: 'Developer earnings for the represented team across fixed reporting windows.', + top_level: true, + columns: [ + c('team_id', 'String', 'ID of the Poki for Developers team.'), + ...quickStatsEarnings('yesterday', 'yesterday'), + ...quickStatsEarnings('last_7_days', 'the seven-day window ending yesterday'), + ...quickStatsEarnings('last_14_days', 'the fourteen-day window ending yesterday'), + ...quickStatsEarnings('last_30_days', 'the thirty-day window ending yesterday'), + ...quickStatsEarnings('current_month', 'the current calendar month through available data'), + ...quickStatsEarnings('all_time', 'all reporting dates through yesterday') + ] + }, + { + name: 'table_update_times', + description: 'Latest successful refresh timestamp reported for each analytics table.', + grain: 'One row per analytics table.', + population: 'Analytics tables that report a successful refresh timestamp.', + top_level: true, + columns: [ + c('table_name', 'String', 'Analytics table name.'), + c('last_updated_at', 'DateTime', 'Europe/Amsterdam local timestamp of the table\'s latest successful refresh; this follows CET/CEST daylight saving.') + ] + }, + { + name: 'pokifordevs_games', + description: 'Join-only current game metadata. Qualify fields with pokifordevs_games and join through p4d_game_id.', + grain: 'One row per Poki for Developers game.', + population: 'Current game metadata available to the analytics join.', + top_level: false, + join_on: 'p4d_game_id', + columns: [ + c('id', 'String', 'Stable Poki for Developers game ID.'), + c('title', 'String', 'Current game title.'), + c('cached_has_revshare', 'Int8', '1 when cached game metadata indicates revenue sharing, otherwise 0.'), + c('approved', 'Int8', '1 when the game is approved, otherwise 0.'), + c('engine', 'String', 'Game engine recorded in the game annotations.') + ] + }, + { + name: 'dbt_p4d_meta_game', + description: 'Join-only release and distribution metadata. Qualify fields with dbt_p4d_meta_game and join through p4d_game_id.', + grain: 'One row per Poki for Developers game.', + population: 'Current release and distribution metadata available to the analytics join.', + top_level: false, + join_on: 'p4d_game_id', + columns: [ + c('p4d_game_id', 'String', 'Stable Poki for Developers game ID.'), + c('release_status', 'String', 'Current game release status: one of not-released, no-link-release, technical-test, soft-release, limited-release, full-release, or 10k-test, each also occurring with a -with-content-restrictions suffix.'), + c('release_status_changed_at', 'DateTime', 'Europe/Amsterdam local timestamp at which the release status last changed; this follows CET/CEST daylight saving.'), + c('release_date', 'Date', 'Europe/Amsterdam calendar date on which the current release phase began.'), + c('num_domains_live', 'UInt64', 'Number of distinct sites or domains on which the game is live.') + ] + } +] + +export function findTable (name: string): TableDefinition | undefined { + return tableCatalog.find(table => table.name === name) +} + +function hasColumn (table: TableDefinition, name: string): boolean { + return table.columns.some(column => column.name === name) +} + +// Advisory cross-check of a locally validated query against the bundled +// snapshot. Unknown names produce warnings, never rejections: the deployed API +// remains authoritative and may know newer tables and columns. +export function snapshotWarnings (query: Record): string[] { + if (typeof query.from !== 'string') return [] + const warnings = new Set() + const missingTable = (name: string): void => { + warnings.add(`table '${name}' is not in the bundled snapshot; the API may reject it`) + } + const from = findTable(query.from) + if (from === undefined) { + missingTable(query.from) + return [...warnings] + } + + const select = Array.isArray(query.select) ? query.select.filter(isRecord) : [] + const outputNames = new Set() + for (const statement of select) { + const outputName = resolvedSelectOutputName(statement) + if (outputName !== undefined) outputNames.add(outputName) + } + + const checkField = (name: unknown, allowOutputName = false): void => { + if (typeof name !== 'string' || (allowOutputName && outputNames.has(name))) return + const separator = name.indexOf('.') + const table = separator === -1 ? from : findTable(name.slice(0, separator)) + if (table === undefined) { + missingTable(name.slice(0, separator)) + return + } + const column = separator === -1 ? name : name.slice(separator + 1) + if (!hasColumn(table, column)) { + warnings.add(`field '${column}' is not a bundled column of table '${table.name}'; the API may reject it`) + } + } + + select.forEach((statement, index) => { + visitSelectExpression(statement, { + field: field => checkField(field) + }, { path: `select[${index}]` }) + }) + if (Array.isArray(query.group)) query.group.forEach(field => checkField(field, true)) + if (Array.isArray(query.order)) query.order.filter(isRecord).forEach(order => checkField(order.field, true)) + if (isRecord(query.include)) Object.keys(query.include).forEach(field => checkField(field, true)) + if (query.where !== undefined) { + // A condition may reference a select output column, so where resolves names + // through the same output-name rule as group, order, and include instead of + // reporting a user-defined alias as a missing bundled column. + visitSelectExpression(query.where, { + field: field => checkField(field, true) + }, { root: 'condition', path: 'where' }) + } + + return [...warnings] +} diff --git a/src/data/examples.ts b/src/data/examples.ts new file mode 100644 index 0000000..76e1b77 --- /dev/null +++ b/src/data/examples.ts @@ -0,0 +1,440 @@ +import { isRecord } from '../json' +export interface DataRecipe { + name: string + description: string + parameters: Record + tables: string[] + query: Record +} + +const teamParameter = { TEAM_ID: 'Authenticated user\'s exact Poki for Developers team ID.' } +const gameParameter = { GAME_ID: 'Poki for Developers game ID.' } +const dateParameters = { + FROM_DATE: 'Inclusive Europe/Amsterdam calendar date in YYYY-MM-DD format.', + TO_DATE: 'Inclusive Europe/Amsterdam calendar date in YYYY-MM-DD format.' +} +const dateTimeParameters = { + FROM_DATETIME: 'Inclusive Europe/Amsterdam local timestamp in YYYY-MM-DD HH:mm:ss format; use CET or CEST according to the date.', + TO_DATETIME: 'Inclusive Europe/Amsterdam local timestamp in YYYY-MM-DD HH:mm:ss format; use CET or CEST according to the date.' +} +const gameDateParameters = { ...teamParameter, ...gameParameter, ...dateParameters } + +const gameDateExpressions = [ + ['team_id', '==', ''], + ['p4d_game_id', '==', ''], + ['date', '>=', ''], + ['date', '<=', ''] +] + +const noLifecycleExpressions = [ + ['dbt_p4d_game_events_v2.starts', '==', 0], + ['dbt_p4d_game_events_v2.completes', '==', 0], + ['dbt_p4d_game_events_v2.fails', '==', 0], + ['dbt_p4d_game_events_v2.seen', '==', 0], + ['dbt_p4d_game_events_v2.interacted', '==', 0], + ['dbt_p4d_game_events_v2.category', '!=', 'funnel'] +] + +function dailyQuery (from: string, select: Array>): Record { + return { + from, + select: [...select, { field: 'date' }], + where: { expressions: gameDateExpressions }, + group: ['date'], + order: [{ field: 'date', direction: 'asc' }] + } +} + +function dailyDeviceQuery ( + from: string, + select: Array>, + expressions: unknown[] = gameDateExpressions +): Record { + return { + from, + select: [...select, { field: 'date' }, { field: 'device_category' }], + where: { expressions }, + group: ['date', 'device_category'], + order: [{ field: 'date', direction: 'asc' }] + } +} + +// Progress-style and interaction-style event exports differ only in which +// lifecycle counters they sum and which must be non-zero. +function lifecycleEventsQuery (sums: string[], nonZero: string[]): Record { + return { + from: 'dbt_p4d_game_events_v2', + select: [ + { field: 'category' }, { field: 'action' }, + ...sums.map(field => ({ field, aggregate: 'sum' })) + ], + where: { + expressions: [ + ...gameDateExpressions, + { + operator: 'or', + expressions: nonZero.map(field => [`dbt_p4d_game_events_v2.${field}`, '>', 0]) + } + ] + }, + group: ['category', 'action'], + order: [{ field: 'gameplays', direction: 'desc' }], + offset: 0, + limit: 10000 + } +} + +// The paginated variant differs from the export only in its page size. +const plainEventTotalsQuery = { + from: 'dbt_p4d_game_events_v2', + select: [{ field: 'category' }, { field: 'action' }, { field: 'label' }, { field: 'gameplays', aggregate: 'sum' }], + where: { expressions: [...gameDateExpressions, ...noLifecycleExpressions] }, + group: ['category', 'action', 'label'], + order: [{ field: 'gameplays', direction: 'desc' }], + offset: 0, + limit: 10000 +} + +export const dataRecipes: DataRecipe[] = [ + { + name: 'team-gameplays', + description: 'Daily gameplay totals for a team, split by device category.', + parameters: { ...teamParameter, ...dateParameters }, + tables: ['dbt_p4d_gameplays'], + query: dailyDeviceQuery('dbt_p4d_gameplays', [{ field: 'gameplays', aggregate: 'sum' }], [ + ['team_id', '==', ''], + ['date', '>=', ''], + ['date', '<=', ''] + ]) + }, + { + name: 'game-earnings', + description: 'Daily direct and shared developer earnings for one game in EUR and USD, split by device category.', + parameters: gameDateParameters, + tables: ['dbt_p4d_developer_earnings'], + query: dailyDeviceQuery('dbt_p4d_developer_earnings', [ + 'developer_earnings_eur', + 'developer_earnings_usd', + 'developer_earnings_shared_eur', + 'developer_earnings_shared_usd' + ].map(field => ({ field, aggregate: 'sum' }))) + }, + { + name: 'game-errors', + description: 'Highest-impact errors for a game, including representative stacks and affected gameplay counts.', + parameters: { ...teamParameter, ...gameParameter, ...dateTimeParameters }, + tables: ['dbt_p4d_game_errors_per_gameplay'], + query: { + from: 'dbt_p4d_game_errors_per_gameplay', + select: [ + { field: 'errors', aggregate: 'sum', alias: 'total_errors' }, + { field: 'gameplay_id', aggregate: 'count', distinct: true, alias: 'gameplays' }, + { field: 'error_name' }, + { field: 'error_message' }, + { field: 'error_stack', aggregate: 'topKWeighted', weight: 'errors' }, + { field: 'stack_line', aggregate: 'topKWeighted', weight: 'errors' }, + { field: 'error_id' }, + { field: 'same_engine_games', aggregate: 'max' } + ], + where: { + expressions: [ + ['team_id', '==', ''], + ['p4d_game_id', '==', ''], + ['date_hour', '>=', ''], + ['date_hour', '<=', ''] + ] + }, + group: ['error_name', 'error_message', 'error_id'], + order: [{ field: 'total_errors', direction: 'desc' }], + offset: 0, + limit: 100 + } + }, + { + name: 'all-game-events', + description: 'All custom event category, action, and label combinations for a game, ordered by gameplay reach.', + parameters: gameDateParameters, + tables: ['dbt_p4d_game_events_v2'], + query: { + from: 'dbt_p4d_game_events_v2', + select: [ + { field: 'category' }, + { field: 'action' }, + { field: 'label' }, + { field: 'gameplays', aggregate: 'sum' } + ], + where: { expressions: gameDateExpressions }, + group: ['category', 'action', 'label'], + order: [{ field: 'gameplays', direction: 'desc' }], + limit: 10000 + } + }, + { + name: 'game-event-starts-export', + description: 'Start, complete, failure, exit, and raw occurrence totals for progress-style events, ready for CSV export.', + parameters: gameDateParameters, + tables: ['dbt_p4d_game_events_v2'], + query: lifecycleEventsQuery( + ['gameplays', 'starts', 'completes', 'fails', 'lefts', 'total_starts', 'total_completes', 'total_fails'], + ['starts', 'completes', 'fails'] + ) + }, + { + name: 'game-event-visibility-export', + description: 'Visible and interacted lifecycle totals for interaction-style events, ready for CSV export.', + parameters: gameDateParameters, + tables: ['dbt_p4d_game_events_v2'], + query: lifecycleEventsQuery( + ['gameplays', 'seen', 'interacted', 'total_seen', 'total_interacted'], + ['seen', 'interacted'] + ) + }, + { + name: 'game-events-export', + description: 'Plain non-lifecycle, non-funnel event totals by category, action, and label, ready for CSV export.', + parameters: gameDateParameters, + tables: ['dbt_p4d_game_events_v2'], + query: plainEventTotalsQuery + }, + { + name: 'game-events', + description: 'Paginated plain non-lifecycle, non-funnel event totals in pages of 100; adjust offset, limit, and order to browse additional rows.', + parameters: gameDateParameters, + tables: ['dbt_p4d_game_events_v2'], + query: { ...plainEventTotalsQuery, limit: 100 } + }, + { + name: 'game-event-time-buckets', + description: 'Dynamic timing histogram for one event and timing type; buckets are measured in seconds.', + parameters: { + ...gameDateParameters, + TIME_TYPE: 'One of event, complete, fail, or interact.', + CATEGORY: 'Exact event category.', + ACTION: 'Exact event action.', + LABEL: 'Exact normalized event label; use an empty string when applicable.' + }, + tables: ['dbt_p4d_game_events_times_v2'], + query: { + from: 'dbt_p4d_game_events_times_v2', + select: [ + { field: 'time_bucket' }, + { field: 'time_granularity', aggregate: 'max', alias: 'time_granularity' }, + { field: 'gameplays', aggregate: 'sum' } + ], + where: { + expressions: [ + ...gameDateExpressions, + ['time_type', '==', ''], + ['category', '==', ''], + ['action', '==', ''], + ['label', '==', '