gamealgo-cli 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import { parseFlags, parseSelectorFlags } from "../args.js";
2
+ import { formatDuration, printResult, reportTimeoutMs, startProgress, withCliMeta } from "../output.js";
3
+ import { objectValue, readJsonFile, writeTextFile } from "../resources.js";
4
+ import { announceTargetGame } from "../target.js";
5
+ import { optionalString } from "../utils.js";
6
+ export async function handleReport(client, args, global) {
7
+ const sub = args.shift();
8
+ if (sub === "pull") {
9
+ const flags = parseFlags(args);
10
+ const pack = await client.getReportPack();
11
+ const out = String(flags.out || "gamealgo-report-pack-v1.json");
12
+ await writeTextFile(out, JSON.stringify(pack.reportPack.content, null, 2) + "\n");
13
+ await printResult({ ok: true, out }, global);
14
+ return;
15
+ }
16
+ if (sub === "validate") {
17
+ const filePath = args.shift();
18
+ if (!filePath)
19
+ throw new Error("usage: gamealgo report validate <report-pack.json>");
20
+ const content = await readJsonFile(filePath);
21
+ await printResult(await client.previewReportPack(content), global);
22
+ return;
23
+ }
24
+ if (sub === "publish") {
25
+ const filePath = args.shift();
26
+ if (!filePath)
27
+ throw new Error("usage: gamealgo report publish <report-pack.json>");
28
+ parseFlags(args);
29
+ const content = await readJsonFile(filePath);
30
+ await announceTargetGame(client);
31
+ const response = await client.putReportPack(content);
32
+ await printResult({ ok: true, validation: response.reportPack.validation }, global);
33
+ return;
34
+ }
35
+ if (sub === "manifest" || sub === "list") {
36
+ parseFlags(args);
37
+ await printResult(await client.reportManifest(), global);
38
+ return;
39
+ }
40
+ if (sub === "result") {
41
+ const selectors = parseSelectorFlags(args);
42
+ const flags = parseFlags(args);
43
+ const startDate = String(flags.from || flags.start || flags.startDate || "");
44
+ const endDate = String(flags.to || flags.end || flags.endDate || "");
45
+ if (!startDate || !endDate)
46
+ throw new Error("usage: gamealgo report result --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v]");
47
+ const timeoutMs = reportTimeoutMs(flags);
48
+ const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
49
+ const startedAt = Date.now();
50
+ const stopProgress = startProgress("Querying report results", timeoutMs);
51
+ let response;
52
+ try {
53
+ response = await client.queryReportDashboard({
54
+ startDate,
55
+ endDate,
56
+ tab: optionalString(flags.tab),
57
+ tabId: optionalString(flags["tab-id"]),
58
+ group: optionalString(flags.group),
59
+ groupId: optionalString(flags["group-id"]),
60
+ chart: optionalString(flags.chart),
61
+ chartId: optionalString(flags["chart-id"]),
62
+ platform,
63
+ selectors,
64
+ refresh: Boolean(flags.refresh),
65
+ }, { timeoutMs });
66
+ }
67
+ finally {
68
+ stopProgress();
69
+ }
70
+ const elapsedMs = Date.now() - startedAt;
71
+ const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
72
+ process.stderr.write(`Report query finished in ${formatDuration(elapsedMs)}.\n`);
73
+ if (flags.out) {
74
+ await writeTextFile(String(flags.out), JSON.stringify(outputValue, null, 2) + "\n");
75
+ await printResult({
76
+ ok: true,
77
+ out: String(flags.out),
78
+ results: Array.isArray(outputValue.results) ? outputValue.results.length : 0,
79
+ elapsedMs,
80
+ }, global);
81
+ return;
82
+ }
83
+ await printResult(outputValue, global);
84
+ return;
85
+ }
86
+ if (sub === "preview") {
87
+ const selectors = parseSelectorFlags(args);
88
+ const flags = parseFlags(args);
89
+ const filePath = String(flags.pack || flags.file || args.shift() || "");
90
+ const startDate = String(flags.from || flags.start || flags.startDate || "");
91
+ const endDate = String(flags.to || flags.end || flags.endDate || "");
92
+ if (!filePath || !startDate || !endDate) {
93
+ throw new Error("usage: gamealgo report preview --pack report-pack.json --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v]");
94
+ }
95
+ const content = await readJsonFile(filePath);
96
+ const timeoutMs = reportTimeoutMs(flags);
97
+ const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
98
+ const startedAt = Date.now();
99
+ const stopProgress = startProgress("Previewing report results", timeoutMs);
100
+ let response;
101
+ try {
102
+ response = await client.previewReportDashboard({
103
+ content,
104
+ startDate,
105
+ endDate,
106
+ tab: optionalString(flags.tab),
107
+ tabId: optionalString(flags["tab-id"]),
108
+ group: optionalString(flags.group),
109
+ groupId: optionalString(flags["group-id"]),
110
+ chart: optionalString(flags.chart),
111
+ chartId: optionalString(flags["chart-id"]),
112
+ platform,
113
+ selectors,
114
+ }, { timeoutMs });
115
+ }
116
+ finally {
117
+ stopProgress();
118
+ }
119
+ const elapsedMs = Date.now() - startedAt;
120
+ const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
121
+ process.stderr.write(`Report preview finished in ${formatDuration(elapsedMs)}.\n`);
122
+ if (flags.out) {
123
+ await writeTextFile(String(flags.out), JSON.stringify(outputValue, null, 2) + "\n");
124
+ await printResult({
125
+ ok: true,
126
+ out: String(flags.out),
127
+ results: Array.isArray(outputValue.results) ? outputValue.results.length : 0,
128
+ elapsedMs,
129
+ }, global);
130
+ return;
131
+ }
132
+ await printResult(outputValue, global);
133
+ return;
134
+ }
135
+ throw new Error("usage: gamealgo report <pull|validate|publish|manifest|result|preview>");
136
+ }
137
+ export async function resolveCliReportPlatform(client, flagValue, selectorValue, startDate, endDate) {
138
+ const flagPlatform = optionalCliReportPlatform(flagValue, "--platform");
139
+ const selectorPlatform = optionalCliReportPlatform(selectorValue, "--selector platform");
140
+ if (flagPlatform && selectorPlatform && flagPlatform !== selectorPlatform) {
141
+ throw new Error(`--platform ${flagPlatform} conflicts with --selector platform=${selectorPlatform}`);
142
+ }
143
+ const explicit = flagPlatform || selectorPlatform;
144
+ if (explicit) {
145
+ process.stderr.write(`Report platform: ${explicit} (explicit)\n`);
146
+ return explicit;
147
+ }
148
+ const preferences = objectValue(await client.getGamePreferences());
149
+ const reportDefaults = objectValue(preferences?.reportDefaults);
150
+ const gameDefault = optionalCliReportPlatform(reportDefaults?.reportPlatform, "game report default");
151
+ if (gameDefault) {
152
+ process.stderr.write(`Report platform: ${gameDefault} (game default)\n`);
153
+ return gameDefault;
154
+ }
155
+ const personalPreferences = objectValue(preferences?.preferences);
156
+ const personalDefault = optionalCliReportPlatform(personalPreferences?.reportPlatform, "saved report preference");
157
+ if (personalDefault) {
158
+ process.stderr.write(`Report platform: ${personalDefault} (saved preference)\n`);
159
+ return personalDefault;
160
+ }
161
+ const detected = objectValue(await client.countEventPlatforms(startDate, endDate));
162
+ const detectedPlatform = optionalCliReportPlatform(detected?.recommendedPlatform, "detected report platform");
163
+ if (!detectedPlatform) {
164
+ throw new Error("report platform could not be detected for the selected date range; pass --platform ios, --platform android, or --platform rest");
165
+ }
166
+ process.stderr.write(`Report platform: ${detectedPlatform} (detected from event data)\n`);
167
+ return detectedPlatform;
168
+ }
169
+ export function optionalCliReportPlatform(value, field) {
170
+ if (value === undefined || value === null || value === "")
171
+ return undefined;
172
+ if (value === "ios" || value === "android" || value === "rest")
173
+ return value;
174
+ throw new Error(`${field} must be ios, android, or rest`);
175
+ }
@@ -0,0 +1,95 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, basename } from "node:path";
3
+ import { parseFlags } from "../args.js";
4
+ import { printResult } from "../output.js";
5
+ import { contentTypeForFileName, latestScriptVersionNames, sanitizeRemoteFileName, validateJsonText, writeTextFile } from "../resources.js";
6
+ import { isScriptFileName, optionalString } from "../utils.js";
7
+ export async function handleFileResource(client, args, global, resource) {
8
+ const sub = args.shift();
9
+ const isScript = resource === "script";
10
+ if (sub === "list") {
11
+ if (isScript) {
12
+ const flags = parseFlags(args);
13
+ await printResult(await client.listScriptVersions(optionalString(flags.name), Number(flags.limit || 50)), global);
14
+ return;
15
+ }
16
+ const files = (await client.listConfigFiles()).configFiles.filter((file) => isScriptFileName(file.name) === isScript);
17
+ await printResult({ files }, global);
18
+ return;
19
+ }
20
+ if (sub === "pull") {
21
+ const flags = parseFlags(args);
22
+ const outDir = String(flags.out || (isScript ? "scripts" : "configs"));
23
+ const all = Boolean(flags.all);
24
+ const names = args;
25
+ if (!all && names.length === 0)
26
+ throw new Error(`usage: gamealgo ${resource} pull <name> [--out dir]`);
27
+ if (isScript) {
28
+ const list = all
29
+ ? latestScriptVersionNames((await client.listScriptVersions(undefined, Number(flags.limit || 500))).scriptVersions)
30
+ : names.map((name) => sanitizeRemoteFileName(name));
31
+ const pulled = [];
32
+ for (const name of list) {
33
+ const versions = (await client.listScriptVersions(name, 1)).scriptVersions;
34
+ const latest = versions[0];
35
+ if (!latest)
36
+ throw new Error(`script version not found: ${name}`);
37
+ const file = await client.getScriptVersion(name, latest.versionId);
38
+ const fileName = sanitizeRemoteFileName(file.scriptVersion.name);
39
+ const target = join(outDir, fileName);
40
+ await writeTextFile(target, file.scriptVersion.content);
41
+ pulled.push(target);
42
+ }
43
+ await printResult({ ok: true, files: pulled }, global);
44
+ return;
45
+ }
46
+ const list = all
47
+ ? (await client.listConfigFiles()).configFiles.filter((file) => isScriptFileName(file.name) === isScript).map((file) => file.name)
48
+ : names;
49
+ const pulled = [];
50
+ for (const name of list) {
51
+ const file = await client.getConfigFile(sanitizeRemoteFileName(name));
52
+ const fileName = sanitizeRemoteFileName(file.configFile.name);
53
+ const target = join(outDir, fileName);
54
+ await writeTextFile(target, file.configFile.content);
55
+ pulled.push(target);
56
+ }
57
+ await printResult({ ok: true, files: pulled }, global);
58
+ return;
59
+ }
60
+ if (sub === "publish") {
61
+ const flags = parseFlags(args);
62
+ const files = args;
63
+ if (files.length === 0)
64
+ throw new Error(`usage: gamealgo ${resource} publish <file...>`);
65
+ if (flags.name && files.length > 1) {
66
+ throw new Error("--name can only be used when publishing one file");
67
+ }
68
+ const published = [];
69
+ for (const filePath of files) {
70
+ const name = sanitizeRemoteFileName(String(flags.name || basename(filePath)));
71
+ const content = await readFile(filePath, "utf8");
72
+ if (isScript) {
73
+ const result = await client.createScriptVersion(name, content, contentTypeForFileName(name), optionalString(flags.message));
74
+ published.push(result.scriptVersion);
75
+ continue;
76
+ }
77
+ if (!isScript && (name.toLowerCase().endsWith(".json") || filePath.toLowerCase().endsWith(".json"))) {
78
+ validateJsonText(content, filePath);
79
+ }
80
+ const result = await client.putConfigFile(name, content, contentTypeForFileName(name));
81
+ published.push(result.configFile);
82
+ }
83
+ await printResult(isScript ? { ok: true, scriptVersions: published } : { ok: true, files: published }, global);
84
+ return;
85
+ }
86
+ if (isScript && (sub === "versions" || sub === "version")) {
87
+ const flags = parseFlags(args);
88
+ const name = optionalString(flags.name) || optionalString(args.shift());
89
+ if (!name)
90
+ throw new Error("usage: gamealgo script versions <name> [--limit 20]");
91
+ await printResult(await client.listScriptVersions(sanitizeRemoteFileName(name), Number(flags.limit || 50)), global);
92
+ return;
93
+ }
94
+ throw new Error(`usage: gamealgo ${resource} <list|pull|publish>`);
95
+ }
@@ -0,0 +1,62 @@
1
+ import { loadPackageMetadata } from "./config.js";
2
+ import { requestJson } from "./http.js";
3
+ export const CLI_PROTOCOL_VERSION = 1;
4
+ export async function assertServerCompatibility(host) {
5
+ const metadata = await requestJson(host, "/admin/v1/cli/meta", {
6
+ headers: { "Cache-Control": "no-cache" },
7
+ });
8
+ const local = await loadPackageMetadata();
9
+ if (metadata.protocolVersion !== CLI_PROTOCOL_VERSION) {
10
+ throw new Error(`This Server requires CLI protocol ${metadata.protocolVersion}, but this CLI supports protocol ${CLI_PROTOCOL_VERSION}. `
11
+ + `Upgrade with: ${metadata.upgradeCommand || `npm install -g ${metadata.packageName || local.name}@latest`}`);
12
+ }
13
+ if (compareVersions(local.version, metadata.minCliVersion) < 0) {
14
+ throw new Error(`gamealgo-cli ${local.version} is no longer supported by this Server; minimum version is ${metadata.minCliVersion}. `
15
+ + `Upgrade with: ${metadata.upgradeCommand || `npm install -g ${metadata.packageName || local.name}@latest`}`);
16
+ }
17
+ return metadata;
18
+ }
19
+ export function compareVersions(left, right) {
20
+ const leftParts = parseVersion(left);
21
+ const rightParts = parseVersion(right);
22
+ for (let index = 0; index < 3; index += 1) {
23
+ if (leftParts[index] !== rightParts[index])
24
+ return leftParts[index] < rightParts[index] ? -1 : 1;
25
+ }
26
+ return comparePrerelease(leftParts[3], rightParts[3]);
27
+ }
28
+ function parseVersion(value) {
29
+ const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
30
+ if (!match)
31
+ throw new Error(`Invalid semantic version: ${value}`);
32
+ return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ? match[4].split(".") : []];
33
+ }
34
+ function comparePrerelease(left, right) {
35
+ if (!left.length && !right.length)
36
+ return 0;
37
+ if (!left.length)
38
+ return 1;
39
+ if (!right.length)
40
+ return -1;
41
+ const length = Math.max(left.length, right.length);
42
+ for (let index = 0; index < length; index += 1) {
43
+ const leftValue = left[index];
44
+ const rightValue = right[index];
45
+ if (leftValue === undefined)
46
+ return -1;
47
+ if (rightValue === undefined)
48
+ return 1;
49
+ if (leftValue === rightValue)
50
+ continue;
51
+ const leftNumber = /^\d+$/.test(leftValue) ? Number(leftValue) : undefined;
52
+ const rightNumber = /^\d+$/.test(rightValue) ? Number(rightValue) : undefined;
53
+ if (leftNumber !== undefined && rightNumber !== undefined)
54
+ return leftNumber < rightNumber ? -1 : 1;
55
+ if (leftNumber !== undefined)
56
+ return -1;
57
+ if (rightNumber !== undefined)
58
+ return 1;
59
+ return leftValue < rightValue ? -1 : 1;
60
+ }
61
+ return 0;
62
+ }
package/dist/config.js ADDED
@@ -0,0 +1,80 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export const GLOBAL_CONFIG_PATH = join(homedir(), ".gamealgo", "cli.json");
5
+ export const PROJECT_CONFIG_DIR = ".gamealgo";
6
+ export const PROJECT_CONFIG_FILE = "cli.json";
7
+ export async function loadConfig() {
8
+ const localPath = await findProjectConfigPath(process.cwd());
9
+ const paths = localPath && localPath !== GLOBAL_CONFIG_PATH
10
+ ? [localPath, GLOBAL_CONFIG_PATH]
11
+ : [GLOBAL_CONFIG_PATH];
12
+ for (const configPath of paths) {
13
+ try {
14
+ return JSON.parse(await readFile(configPath, "utf8"));
15
+ }
16
+ catch {
17
+ // Try the next supported config location.
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ export async function loadProjectConfig() {
23
+ const configPath = await findProjectConfigPath(process.cwd());
24
+ if (!configPath)
25
+ return null;
26
+ try {
27
+ return JSON.parse(await readFile(configPath, "utf8"));
28
+ }
29
+ catch {
30
+ throw new Error(`project CLI config is invalid: ${configPath}`);
31
+ }
32
+ }
33
+ export async function findProjectConfigPath(startDir) {
34
+ let currentDir = startDir;
35
+ while (true) {
36
+ const candidate = join(currentDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE);
37
+ try {
38
+ await readFile(candidate, "utf8");
39
+ return candidate;
40
+ }
41
+ catch {
42
+ const parentDir = dirname(currentDir);
43
+ if (parentDir === currentDir)
44
+ return null;
45
+ currentDir = parentDir;
46
+ }
47
+ }
48
+ }
49
+ export async function saveConfig(config) {
50
+ const configDir = join(process.cwd(), PROJECT_CONFIG_DIR);
51
+ const configPath = join(configDir, PROJECT_CONFIG_FILE);
52
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
53
+ await chmod(configDir, 0o700).catch(() => undefined);
54
+ await ensureProjectConfigIgnored(configDir);
55
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
56
+ await chmod(configPath, 0o600).catch(() => undefined);
57
+ return configPath;
58
+ }
59
+ export async function ensureProjectConfigIgnored(configDir) {
60
+ const ignorePath = join(configDir, ".gitignore");
61
+ try {
62
+ const existing = await readFile(ignorePath, "utf8");
63
+ const rules = existing.split(/\r?\n/).map((line) => line.trim());
64
+ if (rules.includes("*") || rules.includes(PROJECT_CONFIG_FILE) || rules.includes(`/${PROJECT_CONFIG_FILE}`))
65
+ return;
66
+ const separator = existing && !existing.endsWith("\n") ? "\n" : "";
67
+ await writeFile(ignorePath, `${existing}${separator}${PROJECT_CONFIG_FILE}\n`, { encoding: "utf8", mode: 0o600 });
68
+ }
69
+ catch {
70
+ await writeFile(ignorePath, "*\n", { encoding: "utf8", mode: 0o600 });
71
+ }
72
+ }
73
+ export async function loadPackageMetadata() {
74
+ const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
75
+ const name = typeof packageJson.name === "string" ? packageJson.name : "gamealgo-cli";
76
+ if (typeof packageJson.version !== "string" || !packageJson.version) {
77
+ throw new Error("CLI package version is missing");
78
+ }
79
+ return { name, version: packageJson.version };
80
+ }