gamealgo-cli 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -47
- package/dist/args.js +80 -0
- package/dist/client.js +232 -0
- package/dist/commands/auth.js +75 -0
- package/dist/commands/events.js +79 -0
- package/dist/commands/experiment.js +373 -0
- package/dist/commands/integration.js +30 -0
- package/dist/commands/keys.js +113 -0
- package/dist/commands/marketing.js +79 -0
- package/dist/commands/report.js +175 -0
- package/dist/commands/resources.js +95 -0
- package/dist/compatibility.js +62 -0
- package/dist/config.js +80 -0
- package/dist/docs.js +606 -0
- package/dist/http.js +54 -0
- package/dist/main.js +106 -0
- package/dist/output.js +82 -0
- package/dist/resources.js +93 -0
- package/dist/target.js +3 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +23 -0
- package/package.json +16 -7
- package/cli/README.md +0 -433
- package/dist/gamealgo.js +0 -1779
package/dist/gamealgo.js
DELETED
|
@@ -1,1779 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createInterface } from "node:readline/promises";
|
|
3
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname, join, basename } from "node:path";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
|
-
import YAML from "yaml";
|
|
10
|
-
const GLOBAL_CONFIG_PATH = join(homedir(), ".gamealgo", "cli.json");
|
|
11
|
-
const PROJECT_CONFIG_DIR = ".gamealgo";
|
|
12
|
-
const PROJECT_CONFIG_FILE = "cli.json";
|
|
13
|
-
const DOCS_CACHE_ROOT = join(homedir(), ".gamealgo", "docs");
|
|
14
|
-
const SCRIPT_EXTENSIONS = new Set([".js", ".lua"]);
|
|
15
|
-
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
16
|
-
async function main() {
|
|
17
|
-
const args = process.argv.slice(2);
|
|
18
|
-
const global = parseGlobalFlags(args);
|
|
19
|
-
const command = args.shift();
|
|
20
|
-
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
21
|
-
printHelp();
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
if (command === "version" || command === "--version" || command === "-V") {
|
|
25
|
-
const metadata = await loadPackageMetadata();
|
|
26
|
-
if (global.json) {
|
|
27
|
-
await printResult({ ok: true, name: metadata.name, version: metadata.version }, global);
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
console.log(metadata.version);
|
|
31
|
-
}
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
if (command === "login") {
|
|
36
|
-
await login(args, global);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
if (command === "whoami") {
|
|
40
|
-
const client = await createClient(global);
|
|
41
|
-
await printResult(await client.session(), global);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
if (command === "docs") {
|
|
45
|
-
await handleDocs(args, global);
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
if (command === "admin-key") {
|
|
49
|
-
await handleAdminKey(args, global);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const client = await createClient(global);
|
|
53
|
-
if (command === "experiment") {
|
|
54
|
-
await handleExperiment(client, args, global);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (command === "script") {
|
|
58
|
-
await handleFileResource(client, args, global, "script");
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
if (command === "config") {
|
|
62
|
-
await handleFileResource(client, args, global, "config");
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (command === "report") {
|
|
66
|
-
await handleReport(client, args, global);
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (command === "key") {
|
|
70
|
-
await handleGameKey(client, args, global);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (command === "events") {
|
|
74
|
-
await handleEvents(client, args, global);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (command === "marketing") {
|
|
78
|
-
await handleMarketing(client, args, global);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
throw new Error(`Unknown command: ${command}`);
|
|
82
|
-
}
|
|
83
|
-
catch (error) {
|
|
84
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
-
if (global.json) {
|
|
86
|
-
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
console.error(`Error: ${message}`);
|
|
90
|
-
}
|
|
91
|
-
process.exitCode = 1;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
function parseGlobalFlags(args) {
|
|
95
|
-
const global = {
|
|
96
|
-
json: false,
|
|
97
|
-
host: process.env.GAMEALGO_ADMIN_HOST || process.env.GAMEALGO_HOST || "",
|
|
98
|
-
adminKey: process.env.GAMEALGO_GAME_ADMIN_KEY || "",
|
|
99
|
-
adminToken: process.env.GAMEALGO_ADMIN_TOKEN || "",
|
|
100
|
-
};
|
|
101
|
-
for (let index = 0; index < args.length;) {
|
|
102
|
-
const arg = args[index];
|
|
103
|
-
if (arg === "--json") {
|
|
104
|
-
global.json = true;
|
|
105
|
-
args.splice(index, 1);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (arg === "--host") {
|
|
109
|
-
global.host = requireFlagValue(args, index, "--host");
|
|
110
|
-
args.splice(index, 2);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (arg === "--admin-key") {
|
|
114
|
-
global.adminKey = requireFlagValue(args, index, "--admin-key");
|
|
115
|
-
args.splice(index, 2);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (arg === "--admin-token") {
|
|
119
|
-
global.adminToken = requireFlagValue(args, index, "--admin-token");
|
|
120
|
-
args.splice(index, 2);
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
index += 1;
|
|
124
|
-
}
|
|
125
|
-
return global;
|
|
126
|
-
}
|
|
127
|
-
async function login(args, global) {
|
|
128
|
-
const flags = parseFlags(args);
|
|
129
|
-
const host = normalizeHost(String(flags.host || global.host || ""));
|
|
130
|
-
const adminKey = String(flags["admin-key"] || global.adminKey || "");
|
|
131
|
-
if (!host)
|
|
132
|
-
throw new Error("--host is required");
|
|
133
|
-
if (!adminKey)
|
|
134
|
-
throw new Error("--admin-key is required");
|
|
135
|
-
const client = new GameAlgoAdminClient({ host, adminKey });
|
|
136
|
-
const session = await client.session();
|
|
137
|
-
if (session.principal.type !== "game_admin" || !session.principal.gameId) {
|
|
138
|
-
throw new Error("admin key is not scoped to a game");
|
|
139
|
-
}
|
|
140
|
-
const configPath = await saveConfig({ host, adminKey });
|
|
141
|
-
await printResult({
|
|
142
|
-
ok: true,
|
|
143
|
-
host,
|
|
144
|
-
gameId: session.principal.gameId,
|
|
145
|
-
keyName: session.principal.keyName,
|
|
146
|
-
configPath,
|
|
147
|
-
}, global);
|
|
148
|
-
}
|
|
149
|
-
async function handleDocs(args, global) {
|
|
150
|
-
const flags = parseFlags(args);
|
|
151
|
-
const topicId = String(args.shift() || "").trim();
|
|
152
|
-
if (args.length)
|
|
153
|
-
throw new Error("usage: gamealgo docs [topic] [--open] [--json] [--host <admin-url>]");
|
|
154
|
-
const saved = await loadConfig();
|
|
155
|
-
const host = normalizeHost(global.host || saved?.host || "");
|
|
156
|
-
if (!host)
|
|
157
|
-
throw new Error("--host is required before login; after login the saved Admin host is used");
|
|
158
|
-
const loaded = await loadDocsManifest(host);
|
|
159
|
-
const manifest = loaded.manifest;
|
|
160
|
-
const topic = topicId ? manifest.topics.find((item) => item.id === topicId) : null;
|
|
161
|
-
if (topicId && !topic) {
|
|
162
|
-
throw new Error(`Unknown docs topic: ${topicId}. Available topics: ${manifest.topics.map((item) => item.id).join(", ")}`);
|
|
163
|
-
}
|
|
164
|
-
const webUrl = docsWebUrl(host, topic?.id);
|
|
165
|
-
if (flags.open) {
|
|
166
|
-
openExternalUrl(webUrl);
|
|
167
|
-
await printResult({
|
|
168
|
-
ok: true,
|
|
169
|
-
opened: webUrl,
|
|
170
|
-
version: manifest.version,
|
|
171
|
-
...(topic ? { topic: topic.id, title: topic.title } : {}),
|
|
172
|
-
cachedManifest: loaded.cached,
|
|
173
|
-
}, global);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (!topic) {
|
|
177
|
-
if (global.json) {
|
|
178
|
-
await printResult({
|
|
179
|
-
ok: true,
|
|
180
|
-
version: manifest.version,
|
|
181
|
-
updatedAt: manifest.updatedAt,
|
|
182
|
-
defaultTopic: manifest.defaultTopic,
|
|
183
|
-
cached: loaded.cached,
|
|
184
|
-
webUrl,
|
|
185
|
-
topics: manifest.topics,
|
|
186
|
-
}, global);
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
printDocsIndex(manifest, webUrl);
|
|
190
|
-
if (loaded.cached)
|
|
191
|
-
console.error("Warning: using cached documentation index because the Admin host could not be reached");
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const document = await loadDocsDocument(host, manifest, topic, loaded.cached);
|
|
195
|
-
if (global.json) {
|
|
196
|
-
await printResult({
|
|
197
|
-
ok: true,
|
|
198
|
-
version: manifest.version,
|
|
199
|
-
updatedAt: manifest.updatedAt,
|
|
200
|
-
topic: topic.id,
|
|
201
|
-
title: topic.title,
|
|
202
|
-
summary: topic.summary,
|
|
203
|
-
cached: document.cached,
|
|
204
|
-
webUrl,
|
|
205
|
-
rawUrl: docsRawUrl(host, topic.file),
|
|
206
|
-
content: document.content,
|
|
207
|
-
}, global);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
console.log(document.content.trimEnd());
|
|
211
|
-
if (document.cached)
|
|
212
|
-
console.error("Warning: using cached documentation because the Admin host could not be reached");
|
|
213
|
-
}
|
|
214
|
-
async function createClient(global) {
|
|
215
|
-
const saved = await loadConfig();
|
|
216
|
-
const host = normalizeHost(global.host || saved?.host || "");
|
|
217
|
-
const adminKey = global.adminKey || saved?.adminKey || "";
|
|
218
|
-
if (!host || !adminKey) {
|
|
219
|
-
throw new Error("run `gamealgo login --host <url> --admin-key <key>` first");
|
|
220
|
-
}
|
|
221
|
-
return new GameAlgoAdminClient({ host, adminKey });
|
|
222
|
-
}
|
|
223
|
-
async function handleAdminKey(args, global) {
|
|
224
|
-
const sub = args.shift();
|
|
225
|
-
if (sub !== "create")
|
|
226
|
-
throw new Error("usage: gamealgo admin-key create --host <url> --admin-token <token> --game <gameId> --name <name>");
|
|
227
|
-
const flags = parseFlags(args);
|
|
228
|
-
const host = normalizeHost(String(flags.host || global.host || ""));
|
|
229
|
-
const adminToken = String(flags["admin-token"] || global.adminToken || "");
|
|
230
|
-
const gameId = String(flags.game || "");
|
|
231
|
-
const name = String(flags.name || "");
|
|
232
|
-
if (!host)
|
|
233
|
-
throw new Error("--host is required");
|
|
234
|
-
if (!adminToken)
|
|
235
|
-
throw new Error("--admin-token is required");
|
|
236
|
-
if (!gameId)
|
|
237
|
-
throw new Error("--game is required");
|
|
238
|
-
if (!name)
|
|
239
|
-
throw new Error("--name is required");
|
|
240
|
-
const response = await requestJson(host, `/admin/v1/games/${encodeURIComponent(gameId)}/admin-keys`, {
|
|
241
|
-
method: "POST",
|
|
242
|
-
headers: {
|
|
243
|
-
"X-GameAlgo-Admin-Token": adminToken,
|
|
244
|
-
"Content-Type": "application/json",
|
|
245
|
-
},
|
|
246
|
-
body: JSON.stringify({ name }),
|
|
247
|
-
});
|
|
248
|
-
await printResult(response, global);
|
|
249
|
-
}
|
|
250
|
-
async function handleExperiment(client, args, global) {
|
|
251
|
-
const sub = args.shift();
|
|
252
|
-
if (sub === "strategies" || sub === "list") {
|
|
253
|
-
const flags = parseFlags(args);
|
|
254
|
-
await printResult(await client.listExperimentV2Strategies(Boolean(flags["include-archived"] || flags.archived)), global);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (sub === "strategy") {
|
|
258
|
-
await handleExperimentStrategy(client, args, global);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (sub === "run") {
|
|
262
|
-
await handleExperimentRun(client, args, global);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
if (sub === "override") {
|
|
266
|
-
await handleExperimentOverride(client, args, global);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
if (sub === "integration-version") {
|
|
270
|
-
await handleExperimentIntegrationVersion(client, args, global);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
throw new Error("usage: gamealgo experiment <strategies|strategy|run|override|integration-version>");
|
|
274
|
-
}
|
|
275
|
-
async function handleExperimentIntegrationVersion(client, args, global) {
|
|
276
|
-
const sub = args.shift();
|
|
277
|
-
const flags = parseFlags(args);
|
|
278
|
-
if (sub === "latest") {
|
|
279
|
-
await printResult(await client.getLatestExperimentIntegrationVersion(), global);
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
if (sub === "list") {
|
|
283
|
-
const limit = optionalNonNegativeInteger(flags.limit, "--limit") ?? 100;
|
|
284
|
-
await printResult(await client.listExperimentIntegrationVersions(limit), global);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (sub === "get" || sub === "show") {
|
|
288
|
-
const version = positiveInteger(flags.version || args.shift(), "version");
|
|
289
|
-
await printResult(await client.getExperimentIntegrationVersion(version), global);
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
if (sub === "create") {
|
|
293
|
-
const title = optionalString(flags.title);
|
|
294
|
-
const message = optionalString(flags.message);
|
|
295
|
-
if (!title || !message) {
|
|
296
|
-
throw new Error("usage: gamealgo experiment integration-version create --title <title> --message <message>");
|
|
297
|
-
}
|
|
298
|
-
await printResult(await client.createExperimentIntegrationVersion(title, message), global);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
if (sub === "update") {
|
|
302
|
-
const version = positiveInteger(flags.version || args.shift(), "version");
|
|
303
|
-
const message = optionalString(flags.message);
|
|
304
|
-
if (!message) {
|
|
305
|
-
throw new Error("usage: gamealgo experiment integration-version update --version <version> --message <message>");
|
|
306
|
-
}
|
|
307
|
-
await printResult(await client.updateExperimentIntegrationVersion(version, message), global);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
throw new Error("usage: gamealgo experiment integration-version <latest|list|get|create|update>");
|
|
311
|
-
}
|
|
312
|
-
async function handleExperimentStrategy(client, args, global) {
|
|
313
|
-
const sub = args.shift();
|
|
314
|
-
if (sub === "show" || sub === "get") {
|
|
315
|
-
const flags = parseFlags(args);
|
|
316
|
-
const strategyKey = optionalString(flags.strategy || flags.key) || optionalString(args.shift());
|
|
317
|
-
if (!strategyKey)
|
|
318
|
-
throw new Error("usage: gamealgo experiment strategy show <strategyKey>");
|
|
319
|
-
const response = await client.getExperimentV2Strategy(strategyKey);
|
|
320
|
-
if (flags.out) {
|
|
321
|
-
await writeTextFile(String(flags.out), YAML.stringify(response));
|
|
322
|
-
await printResult({ ok: true, out: String(flags.out), strategyKey }, global);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
await printResult(response, global);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (sub === "publish" || sub === "upsert") {
|
|
329
|
-
const flags = parseFlags(args);
|
|
330
|
-
const filePath = optionalString(flags.file || flags.input) || optionalString(args.shift());
|
|
331
|
-
if (!filePath)
|
|
332
|
-
throw new Error("usage: gamealgo experiment strategy publish <strategy.yaml> --yes");
|
|
333
|
-
await requireYes(flags, global, "experiment strategy publish");
|
|
334
|
-
const body = await readObjectFile(filePath, "experiment strategy");
|
|
335
|
-
if (!optionalString(body.displayName)) {
|
|
336
|
-
throw new Error("experiment strategy requires displayName");
|
|
337
|
-
}
|
|
338
|
-
if (!optionalString(body.strategyKey || body.key)) {
|
|
339
|
-
throw new Error("experiment strategy requires strategyKey");
|
|
340
|
-
}
|
|
341
|
-
const requiredIntegrationVersion = optionalNonNegativeInteger(body.requiredIntegrationVersion, "requiredIntegrationVersion");
|
|
342
|
-
if (requiredIntegrationVersion === undefined) {
|
|
343
|
-
throw new Error("experiment strategy requires explicit requiredIntegrationVersion; use 0 when no client integration version is required");
|
|
344
|
-
}
|
|
345
|
-
const response = await client.upsertExperimentV2Strategy(body);
|
|
346
|
-
await printResult(response, global);
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
if (sub === "default") {
|
|
350
|
-
const flags = parseFlags(args);
|
|
351
|
-
const strategyKey = optionalString(flags.strategy || flags.key) || optionalString(args.shift());
|
|
352
|
-
const filePath = optionalString(flags.file || flags.input) || optionalString(args.shift());
|
|
353
|
-
if (!strategyKey || !filePath)
|
|
354
|
-
throw new Error("usage: gamealgo experiment strategy default <strategyKey> <strategy.yaml> --yes");
|
|
355
|
-
await requireYes(flags, global, "experiment strategy default");
|
|
356
|
-
const response = await client.updateExperimentV2StrategyDefault(strategyKey, await readObjectFile(filePath, "experiment strategy default"));
|
|
357
|
-
await printResult(response, global);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
if (sub === "archive" || sub === "restore") {
|
|
361
|
-
const flags = parseFlags(args);
|
|
362
|
-
const strategyKey = optionalString(flags.strategy || flags.key) || optionalString(args.shift());
|
|
363
|
-
if (!strategyKey)
|
|
364
|
-
throw new Error(`usage: gamealgo experiment strategy ${sub} <strategyKey> --yes`);
|
|
365
|
-
await requireYes(flags, global, `experiment strategy ${sub}`);
|
|
366
|
-
await printResult(await client.archiveExperimentV2Strategy(strategyKey, sub === "archive"), global);
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
if (sub === "rollback") {
|
|
370
|
-
const flags = parseFlags(args);
|
|
371
|
-
const strategyKey = optionalString(flags.strategy || flags.key) || optionalString(args.shift());
|
|
372
|
-
const versionId = optionalString(flags.version || flags["version-id"]);
|
|
373
|
-
const message = requiredMessage(flags, "experiment strategy rollback");
|
|
374
|
-
if (!strategyKey || !versionId) {
|
|
375
|
-
throw new Error("usage: gamealgo experiment strategy rollback <strategyKey> --version-id <versionId> --message <reason> --yes");
|
|
376
|
-
}
|
|
377
|
-
await requireYes(flags, global, "experiment strategy rollback");
|
|
378
|
-
await printResult(await client.rollbackExperimentV2Strategy(strategyKey, versionId, message), global);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
throw new Error("usage: gamealgo experiment strategy <show|publish|default|archive|restore|rollback>");
|
|
382
|
-
}
|
|
383
|
-
async function handleExperimentRun(client, args, global) {
|
|
384
|
-
const sub = args.shift();
|
|
385
|
-
if (sub === "create") {
|
|
386
|
-
const flags = parseFlags(args);
|
|
387
|
-
const strategyKey = optionalString(flags.strategy || flags.key) || optionalString(args.shift());
|
|
388
|
-
const filePath = optionalString(flags.file || flags.input) || optionalString(args.shift());
|
|
389
|
-
if (!strategyKey || !filePath)
|
|
390
|
-
throw new Error("usage: gamealgo experiment run create <strategyKey> <run.yaml> [--manual] --yes");
|
|
391
|
-
await requireYes(flags, global, "experiment run create");
|
|
392
|
-
const body = await readObjectFile(filePath, "experiment run");
|
|
393
|
-
if (!optionalString(body.displayName))
|
|
394
|
-
throw new Error("experiment run requires displayName");
|
|
395
|
-
const objectiveTemplate = optionalString(body.objectiveTemplate);
|
|
396
|
-
if (!objectiveTemplate || !["ltv_proxy@1", "active_days@1"].includes(objectiveTemplate)) {
|
|
397
|
-
throw new Error("experiment run requires objectiveTemplate: ltv_proxy@1 or active_days@1");
|
|
398
|
-
}
|
|
399
|
-
if (!optionalString(body.baselineVariantId)) {
|
|
400
|
-
throw new Error("experiment run requires explicit baselineVariantId");
|
|
401
|
-
}
|
|
402
|
-
validateExperimentRunVariantScripts(body.variants);
|
|
403
|
-
const fileRunType = optionalString(body.type);
|
|
404
|
-
const flagRunType = optionalString(flags.type);
|
|
405
|
-
const manualFlag = flags.manual === true;
|
|
406
|
-
if (fileRunType && fileRunType !== "managed" && fileRunType !== "manual") {
|
|
407
|
-
throw new Error("experiment run type must be managed or manual");
|
|
408
|
-
}
|
|
409
|
-
if (flagRunType && flagRunType !== "managed" && flagRunType !== "manual") {
|
|
410
|
-
throw new Error("--type must be managed or manual");
|
|
411
|
-
}
|
|
412
|
-
if (manualFlag && flagRunType && flagRunType !== "manual") {
|
|
413
|
-
throw new Error("--manual conflicts with --type managed");
|
|
414
|
-
}
|
|
415
|
-
const requestedRunType = manualFlag ? "manual" : flagRunType;
|
|
416
|
-
if (requestedRunType && fileRunType && requestedRunType !== fileRunType) {
|
|
417
|
-
throw new Error(`experiment run type conflict: file uses ${fileRunType}, CLI requests ${requestedRunType}`);
|
|
418
|
-
}
|
|
419
|
-
const runType = requestedRunType || fileRunType || "managed";
|
|
420
|
-
body.type = runType;
|
|
421
|
-
if (runType !== "managed" && body.startCondition !== undefined) {
|
|
422
|
-
throw new Error("experiment run startCondition is supported only when type is managed");
|
|
423
|
-
}
|
|
424
|
-
if (runType === "managed") {
|
|
425
|
-
const minWindowDays = Number(body.minWindowDays);
|
|
426
|
-
const maxWindowDays = Number(body.maxWindowDays);
|
|
427
|
-
if (!Number.isInteger(minWindowDays) || minWindowDays < 1 || minWindowDays > 30) {
|
|
428
|
-
throw new Error("managed experiment run requires minWindowDays between 1 and 30");
|
|
429
|
-
}
|
|
430
|
-
if (!Number.isInteger(maxWindowDays) || maxWindowDays < minWindowDays || maxWindowDays > 60) {
|
|
431
|
-
throw new Error("managed experiment run requires maxWindowDays between minWindowDays and 60");
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
else {
|
|
435
|
-
process.stderr.write("Warning: creating a manual experiment. The platform will not automatically extend, conclude, or promote it; use manual mode only when the developer will manage the experiment decision.\n");
|
|
436
|
-
}
|
|
437
|
-
const result = await client.createExperimentV2Run(strategyKey, body);
|
|
438
|
-
await printResult(result, global);
|
|
439
|
-
if (!global.json)
|
|
440
|
-
printExperimentRunVariantScripts(result);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (sub === "show" || sub === "get") {
|
|
444
|
-
const flags = parseFlags(args);
|
|
445
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
446
|
-
if (!runId)
|
|
447
|
-
throw new Error("usage: gamealgo experiment run show <runId>");
|
|
448
|
-
const response = await client.getExperimentV2Run(runId);
|
|
449
|
-
if (flags.out) {
|
|
450
|
-
await writeTextFile(String(flags.out), YAML.stringify(response));
|
|
451
|
-
await printResult({ ok: true, out: String(flags.out), runId }, global);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
await printResult(response, global);
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
if (sub === "status") {
|
|
458
|
-
const flags = parseFlags(args);
|
|
459
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
460
|
-
if (!runId)
|
|
461
|
-
throw new Error("usage: gamealgo experiment run status <runId>");
|
|
462
|
-
const response = await client.getExperimentV2Run(runId);
|
|
463
|
-
const run = response.run;
|
|
464
|
-
if (!run)
|
|
465
|
-
throw new Error(`experiment run not found: ${runId}`);
|
|
466
|
-
await printResult({
|
|
467
|
-
run: {
|
|
468
|
-
id: run.id,
|
|
469
|
-
strategyKey: run.strategyKey,
|
|
470
|
-
displayName: run.displayName,
|
|
471
|
-
type: run.type,
|
|
472
|
-
status: run.status,
|
|
473
|
-
objective: run.objective,
|
|
474
|
-
baselineVariantId: run.baselineVariantId,
|
|
475
|
-
requiredIntegrationVersion: run.requiredIntegrationVersion,
|
|
476
|
-
startedAt: run.startedAt,
|
|
477
|
-
endedAt: run.endedAt,
|
|
478
|
-
promotedVariantId: run.promotedVariantId,
|
|
479
|
-
currentAssessment: run.currentAssessment ?? null,
|
|
480
|
-
},
|
|
481
|
-
}, global);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
if (sub === "evaluate") {
|
|
485
|
-
const flags = parseFlags(args);
|
|
486
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
487
|
-
if (!runId)
|
|
488
|
-
throw new Error("usage: gamealgo experiment run evaluate <runId> --from YYYY-MM-DD --to YYYY-MM-DD --yes");
|
|
489
|
-
await requireYes(flags, global, "experiment run evaluate");
|
|
490
|
-
await printResult(await client.evaluateExperimentV2Run(runId, {
|
|
491
|
-
from: optionalString(flags.from || flags.start || flags.startDate),
|
|
492
|
-
to: optionalString(flags.to || flags.end || flags.endDate),
|
|
493
|
-
days: optionalString(flags.days),
|
|
494
|
-
}), global);
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (sub === "report") {
|
|
498
|
-
const flags = parseFlags(args);
|
|
499
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
500
|
-
if (!runId)
|
|
501
|
-
throw new Error("usage: gamealgo experiment run report <runId> [--round <number> | --report-id <id>]");
|
|
502
|
-
const response = await client.getExperimentV2Run(runId);
|
|
503
|
-
const reports = Array.isArray(response.run?.reports) ? response.run.reports : [];
|
|
504
|
-
const reportId = optionalString(flags.report || flags["report-id"] || flags.id);
|
|
505
|
-
const round = flags.round === undefined ? undefined : positiveInteger(flags.round, "round");
|
|
506
|
-
const latestReport = objectValue(reports[reports.length - 1]);
|
|
507
|
-
const report = reports
|
|
508
|
-
.map((item) => objectValue(item))
|
|
509
|
-
.find((item) => {
|
|
510
|
-
if (!item)
|
|
511
|
-
return false;
|
|
512
|
-
if (reportId)
|
|
513
|
-
return item.id === reportId || item.evaluationId === reportId;
|
|
514
|
-
if (round !== undefined) {
|
|
515
|
-
const managed = objectValue(objectValue(item.report)?.managed);
|
|
516
|
-
return Number(managed?.roundNumber ?? Number(managed?.roundIndex) + 1) === round;
|
|
517
|
-
}
|
|
518
|
-
return item === latestReport;
|
|
519
|
-
});
|
|
520
|
-
if (!report) {
|
|
521
|
-
const selector = reportId ? `: ${reportId}` : round !== undefined ? ` for round ${round}` : "";
|
|
522
|
-
throw new Error(`experiment run report not found${selector}`);
|
|
523
|
-
}
|
|
524
|
-
await printResult({ runId, report }, global);
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
if (sub === "objective") {
|
|
528
|
-
const flags = parseFlags(args);
|
|
529
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
530
|
-
const objectiveTemplate = optionalString(flags.objective || flags["objective-template"]);
|
|
531
|
-
if (!runId || !objectiveTemplate) {
|
|
532
|
-
throw new Error("usage: gamealgo experiment run objective <runId> --objective <ltv_proxy@1|active_days@1> --message <reason> --yes");
|
|
533
|
-
}
|
|
534
|
-
if (!["ltv_proxy@1", "active_days@1"].includes(objectiveTemplate)) {
|
|
535
|
-
throw new Error("experiment objective must be ltv_proxy@1 or active_days@1");
|
|
536
|
-
}
|
|
537
|
-
const message = requiredMessage(flags, "experiment run objective");
|
|
538
|
-
await requireYes(flags, global, "experiment run objective");
|
|
539
|
-
await printResult(await client.updateExperimentV2RunObjective(runId, objectiveTemplate, message), global);
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
if (sub === "promote") {
|
|
543
|
-
const flags = parseFlags(args);
|
|
544
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
545
|
-
const variantId = optionalString(flags.variant || flags["variant-id"]) || optionalString(args.shift());
|
|
546
|
-
if (!runId || !variantId)
|
|
547
|
-
throw new Error("usage: gamealgo experiment run promote <runId> --variant <variantId> --message <reason> --yes");
|
|
548
|
-
const message = requiredMessage(flags, "experiment run promote");
|
|
549
|
-
await requireYes(flags, global, "experiment run promote");
|
|
550
|
-
await printResult(await client.promoteExperimentV2Run(runId, variantId, message), global);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
if (sub === "cancel") {
|
|
554
|
-
const flags = parseFlags(args);
|
|
555
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
556
|
-
if (!runId)
|
|
557
|
-
throw new Error("usage: gamealgo experiment run cancel <runId> --message <reason> --yes");
|
|
558
|
-
const message = requiredMessage(flags, "experiment run cancel");
|
|
559
|
-
await requireYes(flags, global, "experiment run cancel");
|
|
560
|
-
await printResult(await client.cancelExperimentV2Run(runId, message), global);
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
throw new Error("usage: gamealgo experiment run <create|show|status|evaluate|report|objective|promote|cancel>");
|
|
564
|
-
}
|
|
565
|
-
function requiredMessage(flags, action) {
|
|
566
|
-
const message = optionalString(flags.message);
|
|
567
|
-
if (!message)
|
|
568
|
-
throw new Error(`${action} requires --message <reason>`);
|
|
569
|
-
return message;
|
|
570
|
-
}
|
|
571
|
-
async function handleExperimentOverride(client, args, global) {
|
|
572
|
-
const sub = args.shift();
|
|
573
|
-
if (sub === "list") {
|
|
574
|
-
const flags = parseFlags(args);
|
|
575
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
576
|
-
if (!runId)
|
|
577
|
-
throw new Error("usage: gamealgo experiment override list <runId>");
|
|
578
|
-
await printResult(await client.listExperimentV2Overrides(runId), global);
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
if (sub === "set") {
|
|
582
|
-
const flags = parseFlags(args);
|
|
583
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
584
|
-
const userId = optionalString(flags.user || flags["user-id"] || flags.device || flags["device-id"]);
|
|
585
|
-
const variantId = optionalString(flags.variant || flags["variant-id"]);
|
|
586
|
-
if (!runId || !userId || !variantId)
|
|
587
|
-
throw new Error("usage: gamealgo experiment override set <runId> --user <userId> --variant <variantId> --yes");
|
|
588
|
-
await requireYes(flags, global, "experiment override set");
|
|
589
|
-
await printResult(await client.upsertExperimentV2Override(runId, userId, variantId), global);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
if (sub === "delete" || sub === "remove") {
|
|
593
|
-
const flags = parseFlags(args);
|
|
594
|
-
const runId = optionalString(flags.run || flags["run-id"] || flags.id) || optionalString(args.shift());
|
|
595
|
-
const userId = optionalString(flags.user || flags["user-id"] || flags.device || flags["device-id"]);
|
|
596
|
-
if (!runId || !userId)
|
|
597
|
-
throw new Error(`usage: gamealgo experiment override ${sub} <runId> --user <userId> --yes`);
|
|
598
|
-
await requireYes(flags, global, `experiment override ${sub}`);
|
|
599
|
-
await printResult(await client.deleteExperimentV2Override(runId, userId), global);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
throw new Error("usage: gamealgo experiment override <list|set|delete>");
|
|
603
|
-
}
|
|
604
|
-
async function requireYes(flags, global, action) {
|
|
605
|
-
if (flags.yes)
|
|
606
|
-
return;
|
|
607
|
-
if (global.json) {
|
|
608
|
-
throw new Error(`${action} requires --yes in --json mode`);
|
|
609
|
-
}
|
|
610
|
-
if (!isInteractive()) {
|
|
611
|
-
throw new Error(`${action} requires --yes in non-interactive mode`);
|
|
612
|
-
}
|
|
613
|
-
const ok = await confirm(`Run ${action}?`);
|
|
614
|
-
if (!ok) {
|
|
615
|
-
throw new Error(`${action} cancelled`);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
async function handleFileResource(client, args, global, resource) {
|
|
619
|
-
const sub = args.shift();
|
|
620
|
-
const isScript = resource === "script";
|
|
621
|
-
if (sub === "list") {
|
|
622
|
-
if (isScript) {
|
|
623
|
-
const flags = parseFlags(args);
|
|
624
|
-
await printResult(await client.listScriptVersions(optionalString(flags.name), Number(flags.limit || 50)), global);
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
const files = (await client.listConfigFiles()).configFiles.filter((file) => isScriptFileName(file.name) === isScript);
|
|
628
|
-
await printResult({ files }, global);
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
if (sub === "pull") {
|
|
632
|
-
const flags = parseFlags(args);
|
|
633
|
-
const outDir = String(flags.out || (isScript ? "scripts" : "configs"));
|
|
634
|
-
const all = Boolean(flags.all);
|
|
635
|
-
const names = args;
|
|
636
|
-
if (!all && names.length === 0)
|
|
637
|
-
throw new Error(`usage: gamealgo ${resource} pull <name> [--out dir]`);
|
|
638
|
-
if (isScript) {
|
|
639
|
-
const list = all
|
|
640
|
-
? latestScriptVersionNames((await client.listScriptVersions(undefined, Number(flags.limit || 500))).scriptVersions)
|
|
641
|
-
: names.map((name) => sanitizeRemoteFileName(name));
|
|
642
|
-
const pulled = [];
|
|
643
|
-
for (const name of list) {
|
|
644
|
-
const versions = (await client.listScriptVersions(name, 1)).scriptVersions;
|
|
645
|
-
const latest = versions[0];
|
|
646
|
-
if (!latest)
|
|
647
|
-
throw new Error(`script version not found: ${name}`);
|
|
648
|
-
const file = await client.getScriptVersion(name, latest.versionId);
|
|
649
|
-
const fileName = sanitizeRemoteFileName(file.scriptVersion.name);
|
|
650
|
-
const target = join(outDir, fileName);
|
|
651
|
-
await writeTextFile(target, file.scriptVersion.content);
|
|
652
|
-
pulled.push(target);
|
|
653
|
-
}
|
|
654
|
-
await printResult({ ok: true, files: pulled }, global);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
const list = all
|
|
658
|
-
? (await client.listConfigFiles()).configFiles.filter((file) => isScriptFileName(file.name) === isScript).map((file) => file.name)
|
|
659
|
-
: names;
|
|
660
|
-
const pulled = [];
|
|
661
|
-
for (const name of list) {
|
|
662
|
-
const file = await client.getConfigFile(sanitizeRemoteFileName(name));
|
|
663
|
-
const fileName = sanitizeRemoteFileName(file.configFile.name);
|
|
664
|
-
const target = join(outDir, fileName);
|
|
665
|
-
await writeTextFile(target, file.configFile.content);
|
|
666
|
-
pulled.push(target);
|
|
667
|
-
}
|
|
668
|
-
await printResult({ ok: true, files: pulled }, global);
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
if (sub === "publish") {
|
|
672
|
-
const flags = parseFlags(args);
|
|
673
|
-
const files = args;
|
|
674
|
-
if (files.length === 0)
|
|
675
|
-
throw new Error(`usage: gamealgo ${resource} publish <file...>`);
|
|
676
|
-
if (flags.name && files.length > 1) {
|
|
677
|
-
throw new Error("--name can only be used when publishing one file");
|
|
678
|
-
}
|
|
679
|
-
const published = [];
|
|
680
|
-
for (const filePath of files) {
|
|
681
|
-
const name = sanitizeRemoteFileName(String(flags.name || basename(filePath)));
|
|
682
|
-
const content = await readFile(filePath, "utf8");
|
|
683
|
-
if (isScript) {
|
|
684
|
-
const result = await client.createScriptVersion(name, content, contentTypeForFileName(name), optionalString(flags.message));
|
|
685
|
-
published.push(result.scriptVersion);
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
if (!isScript && (name.toLowerCase().endsWith(".json") || filePath.toLowerCase().endsWith(".json"))) {
|
|
689
|
-
validateJsonText(content, filePath);
|
|
690
|
-
}
|
|
691
|
-
const result = await client.putConfigFile(name, content, contentTypeForFileName(name));
|
|
692
|
-
published.push(result.configFile);
|
|
693
|
-
}
|
|
694
|
-
await printResult(isScript ? { ok: true, scriptVersions: published } : { ok: true, files: published }, global);
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
if (isScript && (sub === "versions" || sub === "version")) {
|
|
698
|
-
const flags = parseFlags(args);
|
|
699
|
-
const name = optionalString(flags.name) || optionalString(args.shift());
|
|
700
|
-
if (!name)
|
|
701
|
-
throw new Error("usage: gamealgo script versions <name> [--limit 20]");
|
|
702
|
-
await printResult(await client.listScriptVersions(sanitizeRemoteFileName(name), Number(flags.limit || 50)), global);
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
throw new Error(`usage: gamealgo ${resource} <list|pull|publish>`);
|
|
706
|
-
}
|
|
707
|
-
async function handleReport(client, args, global) {
|
|
708
|
-
const sub = args.shift();
|
|
709
|
-
if (sub === "pull") {
|
|
710
|
-
const flags = parseFlags(args);
|
|
711
|
-
const pack = await client.getReportPack();
|
|
712
|
-
const out = String(flags.out || "gamealgo-report-pack-v1.json");
|
|
713
|
-
await writeTextFile(out, JSON.stringify(pack.reportPack.content, null, 2) + "\n");
|
|
714
|
-
await printResult({ ok: true, out }, global);
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
if (sub === "validate") {
|
|
718
|
-
const filePath = args.shift();
|
|
719
|
-
if (!filePath)
|
|
720
|
-
throw new Error("usage: gamealgo report validate <report-pack.json>");
|
|
721
|
-
const content = await readJsonFile(filePath);
|
|
722
|
-
await printResult(await client.previewReportPack(content), global);
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
if (sub === "publish") {
|
|
726
|
-
const filePath = args.shift();
|
|
727
|
-
if (!filePath)
|
|
728
|
-
throw new Error("usage: gamealgo report publish <report-pack.json>");
|
|
729
|
-
parseFlags(args);
|
|
730
|
-
const content = await readJsonFile(filePath);
|
|
731
|
-
const response = await client.putReportPack(content);
|
|
732
|
-
await printResult({ ok: true, validation: response.reportPack.validation }, global);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
if (sub === "manifest" || sub === "list") {
|
|
736
|
-
parseFlags(args);
|
|
737
|
-
await printResult(await client.reportManifest(), global);
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
if (sub === "result") {
|
|
741
|
-
const selectors = parseSelectorFlags(args);
|
|
742
|
-
const flags = parseFlags(args);
|
|
743
|
-
const startDate = String(flags.from || flags.start || flags.startDate || "");
|
|
744
|
-
const endDate = String(flags.to || flags.end || flags.endDate || "");
|
|
745
|
-
if (!startDate || !endDate)
|
|
746
|
-
throw new Error("usage: gamealgo report result --from YYYY-MM-DD --to YYYY-MM-DD [--tab name] [--group name] [--chart name] [--selector k=v]");
|
|
747
|
-
const timeoutMs = reportTimeoutMs(flags);
|
|
748
|
-
const startedAt = Date.now();
|
|
749
|
-
const stopProgress = startProgress("Querying report results", timeoutMs);
|
|
750
|
-
let response;
|
|
751
|
-
try {
|
|
752
|
-
response = await client.queryReportDashboard({
|
|
753
|
-
startDate,
|
|
754
|
-
endDate,
|
|
755
|
-
tab: optionalString(flags.tab),
|
|
756
|
-
tabId: optionalString(flags["tab-id"]),
|
|
757
|
-
group: optionalString(flags.group),
|
|
758
|
-
groupId: optionalString(flags["group-id"]),
|
|
759
|
-
chart: optionalString(flags.chart),
|
|
760
|
-
chartId: optionalString(flags["chart-id"]),
|
|
761
|
-
selectors,
|
|
762
|
-
refresh: Boolean(flags.refresh),
|
|
763
|
-
}, { timeoutMs });
|
|
764
|
-
}
|
|
765
|
-
finally {
|
|
766
|
-
stopProgress();
|
|
767
|
-
}
|
|
768
|
-
const elapsedMs = Date.now() - startedAt;
|
|
769
|
-
const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
|
|
770
|
-
process.stderr.write(`Report query finished in ${formatDuration(elapsedMs)}.\n`);
|
|
771
|
-
if (flags.out) {
|
|
772
|
-
await writeTextFile(String(flags.out), JSON.stringify(outputValue, null, 2) + "\n");
|
|
773
|
-
await printResult({
|
|
774
|
-
ok: true,
|
|
775
|
-
out: String(flags.out),
|
|
776
|
-
results: Array.isArray(outputValue.results) ? outputValue.results.length : 0,
|
|
777
|
-
elapsedMs,
|
|
778
|
-
}, global);
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
await printResult(outputValue, global);
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
if (sub === "preview") {
|
|
785
|
-
const selectors = parseSelectorFlags(args);
|
|
786
|
-
const flags = parseFlags(args);
|
|
787
|
-
const filePath = String(flags.pack || flags.file || args.shift() || "");
|
|
788
|
-
const startDate = String(flags.from || flags.start || flags.startDate || "");
|
|
789
|
-
const endDate = String(flags.to || flags.end || flags.endDate || "");
|
|
790
|
-
if (!filePath || !startDate || !endDate) {
|
|
791
|
-
throw new Error("usage: gamealgo report preview --pack report-pack.json --from YYYY-MM-DD --to YYYY-MM-DD [--tab name] [--group name] [--chart name] [--selector k=v]");
|
|
792
|
-
}
|
|
793
|
-
const content = await readJsonFile(filePath);
|
|
794
|
-
const timeoutMs = reportTimeoutMs(flags);
|
|
795
|
-
const startedAt = Date.now();
|
|
796
|
-
const stopProgress = startProgress("Previewing report results", timeoutMs);
|
|
797
|
-
let response;
|
|
798
|
-
try {
|
|
799
|
-
response = await client.previewReportDashboard({
|
|
800
|
-
content,
|
|
801
|
-
startDate,
|
|
802
|
-
endDate,
|
|
803
|
-
tab: optionalString(flags.tab),
|
|
804
|
-
tabId: optionalString(flags["tab-id"]),
|
|
805
|
-
group: optionalString(flags.group),
|
|
806
|
-
groupId: optionalString(flags["group-id"]),
|
|
807
|
-
chart: optionalString(flags.chart),
|
|
808
|
-
chartId: optionalString(flags["chart-id"]),
|
|
809
|
-
selectors,
|
|
810
|
-
}, { timeoutMs });
|
|
811
|
-
}
|
|
812
|
-
finally {
|
|
813
|
-
stopProgress();
|
|
814
|
-
}
|
|
815
|
-
const elapsedMs = Date.now() - startedAt;
|
|
816
|
-
const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
|
|
817
|
-
process.stderr.write(`Report preview finished in ${formatDuration(elapsedMs)}.\n`);
|
|
818
|
-
if (flags.out) {
|
|
819
|
-
await writeTextFile(String(flags.out), JSON.stringify(outputValue, null, 2) + "\n");
|
|
820
|
-
await printResult({
|
|
821
|
-
ok: true,
|
|
822
|
-
out: String(flags.out),
|
|
823
|
-
results: Array.isArray(outputValue.results) ? outputValue.results.length : 0,
|
|
824
|
-
elapsedMs,
|
|
825
|
-
}, global);
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
await printResult(outputValue, global);
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
throw new Error("usage: gamealgo report <pull|validate|publish|manifest|result|preview>");
|
|
832
|
-
}
|
|
833
|
-
async function handleEvents(client, args, global) {
|
|
834
|
-
const sub = args.shift();
|
|
835
|
-
if (sub !== "count")
|
|
836
|
-
throw new Error("usage: gamealgo events count [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--event-type level_end]");
|
|
837
|
-
const flags = parseFlags(args);
|
|
838
|
-
const timeoutMs = reportTimeoutMs(flags);
|
|
839
|
-
const startedAt = Date.now();
|
|
840
|
-
const stopProgress = startProgress("Querying event counts", timeoutMs);
|
|
841
|
-
let response;
|
|
842
|
-
try {
|
|
843
|
-
response = await client.countEvents({
|
|
844
|
-
startDate: optionalString(flags.from || flags.start || flags.startDate),
|
|
845
|
-
endDate: optionalString(flags.to || flags.end || flags.endDate),
|
|
846
|
-
eventType: optionalString(flags["event-type"] || flags.eventType),
|
|
847
|
-
}, { timeoutMs });
|
|
848
|
-
}
|
|
849
|
-
finally {
|
|
850
|
-
stopProgress();
|
|
851
|
-
}
|
|
852
|
-
const elapsedMs = Date.now() - startedAt;
|
|
853
|
-
const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
|
|
854
|
-
process.stderr.write(`Event count query finished in ${formatDuration(elapsedMs)}.\n`);
|
|
855
|
-
if (flags.out) {
|
|
856
|
-
await writeTextFile(String(flags.out), JSON.stringify(outputValue, null, 2) + "\n");
|
|
857
|
-
await printResult({ ok: true, out: String(flags.out), elapsedMs }, global);
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
await printResult(outputValue, global);
|
|
861
|
-
}
|
|
862
|
-
async function handleMarketing(client, args, global) {
|
|
863
|
-
const provider = args.shift();
|
|
864
|
-
if (provider !== "adjust")
|
|
865
|
-
throw new Error("usage: gamealgo marketing adjust <get|configure|sync>");
|
|
866
|
-
const sub = args.shift();
|
|
867
|
-
if (sub === "get" || sub === "status") {
|
|
868
|
-
await printResult(await client.getAdjustMarketing(), global);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (sub === "configure" || sub === "set") {
|
|
872
|
-
const flags = parseFlags(args);
|
|
873
|
-
const current = await client.getAdjustMarketing();
|
|
874
|
-
const existing = current.integration || {};
|
|
875
|
-
const appToken = optionalString(flags["app-token"] || flags.appToken || existing.appToken);
|
|
876
|
-
const platform = optionalString(flags.platform || existing.platform) || "ios";
|
|
877
|
-
if (!appToken)
|
|
878
|
-
throw new Error("--app-token is required");
|
|
879
|
-
if (platform !== "ios" && platform !== "android")
|
|
880
|
-
throw new Error("--platform must be ios or android");
|
|
881
|
-
const body = {
|
|
882
|
-
appToken,
|
|
883
|
-
appName: optionalString(flags["app-name"] || flags.appName || existing.appName) || "",
|
|
884
|
-
platform,
|
|
885
|
-
currency: optionalString(flags.currency || existing.currency) || "USD",
|
|
886
|
-
status: optionalString(flags.status || existing.status) || "active",
|
|
887
|
-
};
|
|
888
|
-
const apiToken = optionalString(flags["api-token"] || flags.apiToken);
|
|
889
|
-
if (apiToken)
|
|
890
|
-
body.apiToken = apiToken;
|
|
891
|
-
const result = objectValue(await client.putAdjustMarketing(body));
|
|
892
|
-
await printResult({ ok: true, ...(result || {}) }, global);
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
if (sub === "sync") {
|
|
896
|
-
const flags = parseFlags(args);
|
|
897
|
-
const timeoutMs = reportTimeoutMs(flags);
|
|
898
|
-
const startedAt = Date.now();
|
|
899
|
-
const stopProgress = startProgress("Syncing Adjust marketing spend", timeoutMs);
|
|
900
|
-
let response;
|
|
901
|
-
try {
|
|
902
|
-
response = await client.syncAdjustMarketing({
|
|
903
|
-
startDate: optionalString(flags.from || flags.start || flags.startDate),
|
|
904
|
-
endDate: optionalString(flags.to || flags.end || flags.endDate),
|
|
905
|
-
}, { timeoutMs });
|
|
906
|
-
}
|
|
907
|
-
finally {
|
|
908
|
-
stopProgress();
|
|
909
|
-
}
|
|
910
|
-
const elapsedMs = Date.now() - startedAt;
|
|
911
|
-
process.stderr.write(`Adjust sync finished in ${formatDuration(elapsedMs)}.\n`);
|
|
912
|
-
await printResult(withCliMeta(response, { elapsedMs, timeoutMs }), global);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
throw new Error("usage: gamealgo marketing adjust <get|configure|sync>");
|
|
916
|
-
}
|
|
917
|
-
async function handleGameKey(client, args, global) {
|
|
918
|
-
const sub = args.shift();
|
|
919
|
-
if (sub === "list") {
|
|
920
|
-
const keys = (await client.listGameKeys()).keys
|
|
921
|
-
.filter((key) => key.status === "active")
|
|
922
|
-
.map(publicGameKeyForCli);
|
|
923
|
-
await printResult({ keys }, global);
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
if (sub === "create") {
|
|
927
|
-
const flags = parseFlags(args);
|
|
928
|
-
const name = optionalString(flags.name) || optionalString(args.shift());
|
|
929
|
-
if (!name)
|
|
930
|
-
throw new Error("usage: gamealgo key create --name <key-name>");
|
|
931
|
-
const response = await client.createGameKey(name);
|
|
932
|
-
await printResult({
|
|
933
|
-
ok: true,
|
|
934
|
-
created: response.created !== false,
|
|
935
|
-
name: response.key.name,
|
|
936
|
-
key: response.rawKey,
|
|
937
|
-
prefix: response.key.keyPrefix,
|
|
938
|
-
keyPrefix: response.key.keyPrefix,
|
|
939
|
-
status: response.key.status,
|
|
940
|
-
createdAt: response.key.createdAt,
|
|
941
|
-
}, global);
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
if (sub === "reveal") {
|
|
945
|
-
const selector = keySelectorFromArgs(args, "gamealgo key reveal --name <key-name>");
|
|
946
|
-
const response = await client.revealGameKey(selector);
|
|
947
|
-
await printResult({
|
|
948
|
-
ok: true,
|
|
949
|
-
name: response.key.name,
|
|
950
|
-
key: response.rawKey,
|
|
951
|
-
prefix: response.key.keyPrefix,
|
|
952
|
-
keyPrefix: response.key.keyPrefix,
|
|
953
|
-
status: response.key.status,
|
|
954
|
-
createdAt: response.key.createdAt,
|
|
955
|
-
}, global);
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
if (sub === "revoke") {
|
|
959
|
-
const flags = parseFlags(args);
|
|
960
|
-
if (!flags.yes && global.json) {
|
|
961
|
-
throw new Error("key revoke requires --yes in --json mode");
|
|
962
|
-
}
|
|
963
|
-
if (!flags.yes && !isInteractive()) {
|
|
964
|
-
throw new Error("key revoke requires --yes in non-interactive mode");
|
|
965
|
-
}
|
|
966
|
-
const selector = keySelectorFromFlags(flags, args, "gamealgo key revoke --name <key-name> --yes");
|
|
967
|
-
const key = await resolveGameKey(client, selector);
|
|
968
|
-
if (!flags.yes) {
|
|
969
|
-
const ok = await confirm(`Revoke client game key ${key.name} (${key.keyPrefix})?`);
|
|
970
|
-
if (!ok)
|
|
971
|
-
throw new Error("revoke cancelled");
|
|
972
|
-
}
|
|
973
|
-
const response = await client.revokeGameKey(key.id);
|
|
974
|
-
await printResult({ ok: true, key: publicGameKeyForCli(response.key) }, global);
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
throw new Error("usage: gamealgo key <list|create|reveal|revoke>");
|
|
978
|
-
}
|
|
979
|
-
function publicGameKeyForCli(key) {
|
|
980
|
-
return {
|
|
981
|
-
id: key.id,
|
|
982
|
-
name: key.name,
|
|
983
|
-
prefix: key.keyPrefix,
|
|
984
|
-
keyPrefix: key.keyPrefix,
|
|
985
|
-
status: key.status,
|
|
986
|
-
createdAt: key.createdAt,
|
|
987
|
-
};
|
|
988
|
-
}
|
|
989
|
-
function keySelectorFromArgs(args, usage) {
|
|
990
|
-
const flags = parseFlags(args);
|
|
991
|
-
return keySelectorFromFlags(flags, args, usage);
|
|
992
|
-
}
|
|
993
|
-
function keySelectorFromFlags(flags, args, usage) {
|
|
994
|
-
const id = optionalString(flags.id);
|
|
995
|
-
const name = optionalString(flags.name);
|
|
996
|
-
const prefix = optionalString(flags.prefix) || optionalString(flags["key-prefix"]);
|
|
997
|
-
const positionalName = !id && !name && !prefix ? optionalString(args.find((arg) => !arg.startsWith("--"))) : undefined;
|
|
998
|
-
const selector = {
|
|
999
|
-
id,
|
|
1000
|
-
name: name || positionalName,
|
|
1001
|
-
prefix,
|
|
1002
|
-
};
|
|
1003
|
-
if (!selector.id && !selector.name && !selector.prefix)
|
|
1004
|
-
throw new Error(`usage: ${usage}`);
|
|
1005
|
-
return selector;
|
|
1006
|
-
}
|
|
1007
|
-
async function resolveGameKey(client, selector) {
|
|
1008
|
-
if (selector.id) {
|
|
1009
|
-
const key = (await client.listGameKeys()).keys.find((item) => item.status === "active" && item.id === selector.id);
|
|
1010
|
-
if (!key)
|
|
1011
|
-
throw new Error(`client game key not found: ${selector.id}`);
|
|
1012
|
-
return key;
|
|
1013
|
-
}
|
|
1014
|
-
if (selector.name) {
|
|
1015
|
-
const key = (await client.listGameKeys()).keys.find((item) => item.status === "active" && item.name === selector.name);
|
|
1016
|
-
if (!key)
|
|
1017
|
-
throw new Error(`client game key not found: ${selector.name}`);
|
|
1018
|
-
return key;
|
|
1019
|
-
}
|
|
1020
|
-
const key = (await client.listGameKeys()).keys.find((item) => item.status === "active" && item.keyPrefix === selector.prefix);
|
|
1021
|
-
if (!key)
|
|
1022
|
-
throw new Error(`client game key not found: ${selector.prefix}`);
|
|
1023
|
-
return key;
|
|
1024
|
-
}
|
|
1025
|
-
class GameAlgoAdminClient {
|
|
1026
|
-
host;
|
|
1027
|
-
adminKey;
|
|
1028
|
-
sessionCache;
|
|
1029
|
-
constructor(config) {
|
|
1030
|
-
this.host = normalizeHost(config.host);
|
|
1031
|
-
this.adminKey = config.adminKey;
|
|
1032
|
-
}
|
|
1033
|
-
async session() {
|
|
1034
|
-
this.sessionCache ??= await this.get("/admin/v1/session");
|
|
1035
|
-
return this.sessionCache;
|
|
1036
|
-
}
|
|
1037
|
-
async gameId() {
|
|
1038
|
-
const session = await this.session();
|
|
1039
|
-
const gameId = session.principal.gameId;
|
|
1040
|
-
if (!gameId)
|
|
1041
|
-
throw new Error("current credential is not scoped to a game");
|
|
1042
|
-
return gameId;
|
|
1043
|
-
}
|
|
1044
|
-
async listExperimentV2Strategies(includeArchived = false) {
|
|
1045
|
-
const query = includeArchived ? "?includeArchived=1" : "";
|
|
1046
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2${query}`);
|
|
1047
|
-
}
|
|
1048
|
-
async listExperimentIntegrationVersions(limit = 100) {
|
|
1049
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-integration-versions?limit=${encodeURIComponent(String(limit))}`);
|
|
1050
|
-
}
|
|
1051
|
-
async getLatestExperimentIntegrationVersion() {
|
|
1052
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-integration-versions/latest`);
|
|
1053
|
-
}
|
|
1054
|
-
async getExperimentIntegrationVersion(version) {
|
|
1055
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-integration-versions/${version}`);
|
|
1056
|
-
}
|
|
1057
|
-
async createExperimentIntegrationVersion(title, message) {
|
|
1058
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-integration-versions`, { title, message });
|
|
1059
|
-
}
|
|
1060
|
-
async updateExperimentIntegrationVersion(version, message) {
|
|
1061
|
-
return await this.patch(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-integration-versions/${version}`, { message });
|
|
1062
|
-
}
|
|
1063
|
-
async getExperimentV2Strategy(strategyKey) {
|
|
1064
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}`);
|
|
1065
|
-
}
|
|
1066
|
-
async upsertExperimentV2Strategy(body) {
|
|
1067
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2`, body);
|
|
1068
|
-
}
|
|
1069
|
-
async updateExperimentV2StrategyDefault(strategyKey, body) {
|
|
1070
|
-
return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}/default`, body);
|
|
1071
|
-
}
|
|
1072
|
-
async archiveExperimentV2Strategy(strategyKey, archived) {
|
|
1073
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}/archive`, { archived });
|
|
1074
|
-
}
|
|
1075
|
-
async rollbackExperimentV2Strategy(strategyKey, versionId, message) {
|
|
1076
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}/rollback`, {
|
|
1077
|
-
versionId,
|
|
1078
|
-
message,
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
async createExperimentV2Run(strategyKey, body) {
|
|
1082
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}/runs`, body);
|
|
1083
|
-
}
|
|
1084
|
-
async getExperimentV2Run(runId) {
|
|
1085
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}`);
|
|
1086
|
-
}
|
|
1087
|
-
async evaluateExperimentV2Run(runId, query) {
|
|
1088
|
-
const params = new URLSearchParams();
|
|
1089
|
-
if (query.from)
|
|
1090
|
-
params.set("from", query.from);
|
|
1091
|
-
if (query.to)
|
|
1092
|
-
params.set("to", query.to);
|
|
1093
|
-
if (query.days)
|
|
1094
|
-
params.set("days", query.days);
|
|
1095
|
-
if (query.platform)
|
|
1096
|
-
params.set("platform", query.platform);
|
|
1097
|
-
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
1098
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/evaluate${suffix}`, {});
|
|
1099
|
-
}
|
|
1100
|
-
async promoteExperimentV2Run(runId, variantId, message) {
|
|
1101
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/promote`, {
|
|
1102
|
-
variantId,
|
|
1103
|
-
message,
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
async cancelExperimentV2Run(runId, message) {
|
|
1107
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/cancel`, { message });
|
|
1108
|
-
}
|
|
1109
|
-
async updateExperimentV2RunObjective(runId, objectiveTemplate, message) {
|
|
1110
|
-
return await this.patch(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/objective`, {
|
|
1111
|
-
objectiveTemplate,
|
|
1112
|
-
message,
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
async listExperimentV2Overrides(runId) {
|
|
1116
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/overrides`);
|
|
1117
|
-
}
|
|
1118
|
-
async upsertExperimentV2Override(runId, userId, variantId) {
|
|
1119
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/overrides`, { userId, variantId });
|
|
1120
|
-
}
|
|
1121
|
-
async deleteExperimentV2Override(runId, userId) {
|
|
1122
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/experiment-runs-v2/${encodeURIComponent(runId)}/overrides/delete`, { userId });
|
|
1123
|
-
}
|
|
1124
|
-
async listConfigFiles() {
|
|
1125
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/config-files`);
|
|
1126
|
-
}
|
|
1127
|
-
async getConfigFile(name) {
|
|
1128
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/config-files/${encodeURIComponent(name)}`);
|
|
1129
|
-
}
|
|
1130
|
-
async putConfigFile(name, content, contentType) {
|
|
1131
|
-
return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/config-files/${encodeURIComponent(name)}`, { content, contentType });
|
|
1132
|
-
}
|
|
1133
|
-
async listScriptVersions(name, limit = 50) {
|
|
1134
|
-
const params = new URLSearchParams();
|
|
1135
|
-
if (name)
|
|
1136
|
-
params.set("name", name);
|
|
1137
|
-
params.set("limit", String(limit));
|
|
1138
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/script-versions?${params.toString()}`);
|
|
1139
|
-
}
|
|
1140
|
-
async createScriptVersion(name, content, contentType, message) {
|
|
1141
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/scripts/${encodeURIComponent(name)}/versions`, {
|
|
1142
|
-
content,
|
|
1143
|
-
contentType,
|
|
1144
|
-
...(message ? { message } : {}),
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
async getScriptVersion(name, versionId) {
|
|
1148
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/scripts/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`);
|
|
1149
|
-
}
|
|
1150
|
-
async getReportPack() {
|
|
1151
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack`);
|
|
1152
|
-
}
|
|
1153
|
-
async previewReportPack(content) {
|
|
1154
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack/preview`, { content });
|
|
1155
|
-
}
|
|
1156
|
-
async putReportPack(content) {
|
|
1157
|
-
return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack`, { content });
|
|
1158
|
-
}
|
|
1159
|
-
async reportManifest() {
|
|
1160
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/manifest`);
|
|
1161
|
-
}
|
|
1162
|
-
async queryReportDashboard(body, options = {}) {
|
|
1163
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/query`, body, options);
|
|
1164
|
-
}
|
|
1165
|
-
async previewReportDashboard(body, options = {}) {
|
|
1166
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/preview`, body, options);
|
|
1167
|
-
}
|
|
1168
|
-
async countEvents(body, options = {}) {
|
|
1169
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/events/count`, body, options);
|
|
1170
|
-
}
|
|
1171
|
-
async getAdjustMarketing() {
|
|
1172
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/marketing/adjust`);
|
|
1173
|
-
}
|
|
1174
|
-
async putAdjustMarketing(body) {
|
|
1175
|
-
return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/marketing/adjust`, body);
|
|
1176
|
-
}
|
|
1177
|
-
async syncAdjustMarketing(body, options = {}) {
|
|
1178
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/marketing/adjust/sync`, body, options);
|
|
1179
|
-
}
|
|
1180
|
-
async listGameKeys() {
|
|
1181
|
-
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys`);
|
|
1182
|
-
}
|
|
1183
|
-
async createGameKey(name) {
|
|
1184
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys`, { name });
|
|
1185
|
-
}
|
|
1186
|
-
async revealGameKey(selector) {
|
|
1187
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys/reveal`, selector);
|
|
1188
|
-
}
|
|
1189
|
-
async revokeGameKey(id) {
|
|
1190
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys/${encodeURIComponent(id)}/revoke`, {});
|
|
1191
|
-
}
|
|
1192
|
-
async get(path) {
|
|
1193
|
-
return await requestJson(this.host, path, { headers: this.headers() });
|
|
1194
|
-
}
|
|
1195
|
-
async post(path, body, options = {}) {
|
|
1196
|
-
return await requestJson(this.host, path, {
|
|
1197
|
-
method: "POST",
|
|
1198
|
-
headers: this.headers({ "Content-Type": "application/json" }),
|
|
1199
|
-
body: JSON.stringify(body),
|
|
1200
|
-
timeoutMs: options.timeoutMs,
|
|
1201
|
-
});
|
|
1202
|
-
}
|
|
1203
|
-
async put(path, body) {
|
|
1204
|
-
return await requestJson(this.host, path, {
|
|
1205
|
-
method: "PUT",
|
|
1206
|
-
headers: this.headers({ "Content-Type": "application/json" }),
|
|
1207
|
-
body: JSON.stringify(body),
|
|
1208
|
-
});
|
|
1209
|
-
}
|
|
1210
|
-
async patch(path, body) {
|
|
1211
|
-
return await requestJson(this.host, path, {
|
|
1212
|
-
method: "PATCH",
|
|
1213
|
-
headers: this.headers({ "Content-Type": "application/json" }),
|
|
1214
|
-
body: JSON.stringify(body),
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
headers(extra = {}) {
|
|
1218
|
-
return {
|
|
1219
|
-
"X-GameAlgo-Game-Admin-Key": this.adminKey,
|
|
1220
|
-
...extra,
|
|
1221
|
-
};
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
async function requestJson(host, path, init) {
|
|
1225
|
-
const { timeoutMs, ...requestInit } = init;
|
|
1226
|
-
let response;
|
|
1227
|
-
try {
|
|
1228
|
-
response = await fetch(`${normalizeHost(host)}${path}`, {
|
|
1229
|
-
...requestInit,
|
|
1230
|
-
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : requestInit.signal,
|
|
1231
|
-
});
|
|
1232
|
-
}
|
|
1233
|
-
catch (error) {
|
|
1234
|
-
if (isTimeoutError(error)) {
|
|
1235
|
-
throw new Error(`HTTP request timed out after ${formatDuration(timeoutMs ?? 0)}`);
|
|
1236
|
-
}
|
|
1237
|
-
throw error;
|
|
1238
|
-
}
|
|
1239
|
-
const text = await response.text();
|
|
1240
|
-
const json = parseJsonResponse(text);
|
|
1241
|
-
if (!response.ok) {
|
|
1242
|
-
throw new Error(formatHttpError(response.status, response.statusText, text, json));
|
|
1243
|
-
}
|
|
1244
|
-
if (text && json === undefined) {
|
|
1245
|
-
throw new Error(`Invalid JSON response from ${path}: ${truncateBody(text)}`);
|
|
1246
|
-
}
|
|
1247
|
-
return json ?? null;
|
|
1248
|
-
}
|
|
1249
|
-
async function loadDocsManifest(host) {
|
|
1250
|
-
const cachePath = join(docsCacheDir(host), "manifest.json");
|
|
1251
|
-
try {
|
|
1252
|
-
const text = await requestTextUrl(`${docsContentBaseUrl(host)}manifest.json`);
|
|
1253
|
-
const manifest = parseDocsManifest(text);
|
|
1254
|
-
await writePrivateFile(cachePath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1255
|
-
return { manifest, cached: false };
|
|
1256
|
-
}
|
|
1257
|
-
catch (networkError) {
|
|
1258
|
-
try {
|
|
1259
|
-
const manifest = parseDocsManifest(await readFile(cachePath, "utf8"));
|
|
1260
|
-
return { manifest, cached: true };
|
|
1261
|
-
}
|
|
1262
|
-
catch {
|
|
1263
|
-
throw networkError;
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
async function loadDocsDocument(host, manifest, topic, preferCache) {
|
|
1268
|
-
const cachePath = join(docsCacheDir(host), manifest.version || "current", topic.file);
|
|
1269
|
-
if (!preferCache) {
|
|
1270
|
-
try {
|
|
1271
|
-
const content = await requestTextUrl(docsRawUrl(host, topic.file));
|
|
1272
|
-
await writePrivateFile(cachePath, content);
|
|
1273
|
-
return { content, cached: false };
|
|
1274
|
-
}
|
|
1275
|
-
catch {
|
|
1276
|
-
// Fall through to the most recent cached copy.
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
try {
|
|
1280
|
-
return { content: await readFile(cachePath, "utf8"), cached: true };
|
|
1281
|
-
}
|
|
1282
|
-
catch {
|
|
1283
|
-
if (preferCache) {
|
|
1284
|
-
throw new Error(`Documentation topic is not cached and the Admin host is unavailable: ${topic.id}`);
|
|
1285
|
-
}
|
|
1286
|
-
throw new Error(`Unable to download documentation topic: ${topic.id}`);
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
async function requestTextUrl(url) {
|
|
1290
|
-
let response;
|
|
1291
|
-
try {
|
|
1292
|
-
response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
1293
|
-
}
|
|
1294
|
-
catch (error) {
|
|
1295
|
-
if (isTimeoutError(error))
|
|
1296
|
-
throw new Error(`Documentation request timed out: ${url}`);
|
|
1297
|
-
throw new Error(`Documentation request failed: ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1298
|
-
}
|
|
1299
|
-
const text = await response.text();
|
|
1300
|
-
if (!response.ok) {
|
|
1301
|
-
throw new Error(`Documentation request returned ${response.status}: ${truncateBody(text)}`);
|
|
1302
|
-
}
|
|
1303
|
-
return text;
|
|
1304
|
-
}
|
|
1305
|
-
function parseDocsManifest(text) {
|
|
1306
|
-
let value;
|
|
1307
|
-
try {
|
|
1308
|
-
value = JSON.parse(text);
|
|
1309
|
-
}
|
|
1310
|
-
catch {
|
|
1311
|
-
throw new Error("Documentation manifest is not valid JSON");
|
|
1312
|
-
}
|
|
1313
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1314
|
-
throw new Error("Documentation manifest must be an object");
|
|
1315
|
-
}
|
|
1316
|
-
const body = value;
|
|
1317
|
-
if (body.schemaVersion !== 1 || !Array.isArray(body.topics) || !body.topics.length) {
|
|
1318
|
-
throw new Error("Documentation manifest schema is invalid");
|
|
1319
|
-
}
|
|
1320
|
-
const topics = body.topics.map((item) => {
|
|
1321
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1322
|
-
throw new Error("Documentation manifest contains an invalid topic");
|
|
1323
|
-
}
|
|
1324
|
-
const topic = item;
|
|
1325
|
-
const id = String(topic.id || "");
|
|
1326
|
-
const file = String(topic.file || "");
|
|
1327
|
-
if (!/^[a-z][a-z0-9-]{0,63}$/.test(id) || !/^[a-z][a-z0-9-]{0,63}\.md$/.test(file)) {
|
|
1328
|
-
throw new Error(`Documentation manifest contains an unsafe topic: ${id || file}`);
|
|
1329
|
-
}
|
|
1330
|
-
return {
|
|
1331
|
-
id,
|
|
1332
|
-
title: String(topic.title || id),
|
|
1333
|
-
summary: String(topic.summary || ""),
|
|
1334
|
-
group: String(topic.group || "其他"),
|
|
1335
|
-
file,
|
|
1336
|
-
};
|
|
1337
|
-
});
|
|
1338
|
-
const ids = new Set(topics.map((topic) => topic.id));
|
|
1339
|
-
if (ids.size !== topics.length)
|
|
1340
|
-
throw new Error("Documentation manifest contains duplicate topic ids");
|
|
1341
|
-
const defaultTopic = String(body.defaultTopic || topics[0].id);
|
|
1342
|
-
if (!ids.has(defaultTopic))
|
|
1343
|
-
throw new Error("Documentation manifest defaultTopic does not exist");
|
|
1344
|
-
return {
|
|
1345
|
-
schemaVersion: 1,
|
|
1346
|
-
version: String(body.version || "current"),
|
|
1347
|
-
updatedAt: String(body.updatedAt || ""),
|
|
1348
|
-
defaultTopic,
|
|
1349
|
-
topics,
|
|
1350
|
-
};
|
|
1351
|
-
}
|
|
1352
|
-
function docsCacheDir(host) {
|
|
1353
|
-
const key = createHash("sha256").update(normalizeHost(host)).digest("hex").slice(0, 16);
|
|
1354
|
-
return join(DOCS_CACHE_ROOT, key);
|
|
1355
|
-
}
|
|
1356
|
-
function docsWebUrl(host, topicId) {
|
|
1357
|
-
const url = new URL(`${normalizeHost(host)}/dashboard/docs/`);
|
|
1358
|
-
if (topicId)
|
|
1359
|
-
url.searchParams.set("topic", topicId);
|
|
1360
|
-
return url.toString();
|
|
1361
|
-
}
|
|
1362
|
-
function docsContentBaseUrl(host) {
|
|
1363
|
-
return `${normalizeHost(host)}/dashboard/docs/content/`;
|
|
1364
|
-
}
|
|
1365
|
-
function docsRawUrl(host, file) {
|
|
1366
|
-
return `${docsContentBaseUrl(host)}${encodeURIComponent(file)}`;
|
|
1367
|
-
}
|
|
1368
|
-
async function writePrivateFile(filePath, content) {
|
|
1369
|
-
await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1370
|
-
await chmod(dirname(filePath), 0o700).catch(() => undefined);
|
|
1371
|
-
await writeFile(filePath, content, { encoding: "utf8", mode: 0o600 });
|
|
1372
|
-
await chmod(filePath, 0o600).catch(() => undefined);
|
|
1373
|
-
}
|
|
1374
|
-
function printDocsIndex(manifest, webUrl) {
|
|
1375
|
-
console.log(`GameAlgo AI 文档 ${manifest.version}${manifest.updatedAt ? ` (${manifest.updatedAt})` : ""}`);
|
|
1376
|
-
let currentGroup = "";
|
|
1377
|
-
for (const topic of manifest.topics) {
|
|
1378
|
-
if (topic.group !== currentGroup) {
|
|
1379
|
-
currentGroup = topic.group;
|
|
1380
|
-
console.log(`\n${currentGroup}`);
|
|
1381
|
-
}
|
|
1382
|
-
console.log(` ${topic.id}\n ${topic.title}:${topic.summary}`);
|
|
1383
|
-
}
|
|
1384
|
-
console.log(`\n读取文档: gamealgo docs <topic>`);
|
|
1385
|
-
console.log(`网页目录: ${webUrl}`);
|
|
1386
|
-
}
|
|
1387
|
-
function openExternalUrl(url) {
|
|
1388
|
-
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
1389
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1390
|
-
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
1391
|
-
child.on("error", (error) => console.error(`Unable to open browser: ${error.message}`));
|
|
1392
|
-
child.unref();
|
|
1393
|
-
}
|
|
1394
|
-
function parseFlags(args) {
|
|
1395
|
-
const flags = {};
|
|
1396
|
-
for (let index = 0; index < args.length;) {
|
|
1397
|
-
const arg = args[index];
|
|
1398
|
-
if (!arg.startsWith("--")) {
|
|
1399
|
-
index += 1;
|
|
1400
|
-
continue;
|
|
1401
|
-
}
|
|
1402
|
-
const key = arg.slice(2);
|
|
1403
|
-
const next = args[index + 1];
|
|
1404
|
-
if (!next || next.startsWith("--")) {
|
|
1405
|
-
flags[key] = true;
|
|
1406
|
-
args.splice(index, 1);
|
|
1407
|
-
}
|
|
1408
|
-
else {
|
|
1409
|
-
flags[key] = next;
|
|
1410
|
-
args.splice(index, 2);
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
return flags;
|
|
1414
|
-
}
|
|
1415
|
-
function parseSelectorFlags(args) {
|
|
1416
|
-
const selectors = {};
|
|
1417
|
-
for (let index = 0; index < args.length;) {
|
|
1418
|
-
if (args[index] !== "--selector") {
|
|
1419
|
-
index += 1;
|
|
1420
|
-
continue;
|
|
1421
|
-
}
|
|
1422
|
-
const value = requireFlagValue(args, index, "--selector");
|
|
1423
|
-
const separator = value.indexOf("=");
|
|
1424
|
-
if (separator <= 0)
|
|
1425
|
-
throw new Error("--selector must use key=value");
|
|
1426
|
-
const key = value.slice(0, separator).trim();
|
|
1427
|
-
const selectorValue = value.slice(separator + 1).trim();
|
|
1428
|
-
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key))
|
|
1429
|
-
throw new Error(`invalid selector id: ${key}`);
|
|
1430
|
-
selectors[key] = selectorValue;
|
|
1431
|
-
args.splice(index, 2);
|
|
1432
|
-
}
|
|
1433
|
-
return selectors;
|
|
1434
|
-
}
|
|
1435
|
-
function requireFlagValue(args, index, flag) {
|
|
1436
|
-
const value = args[index + 1];
|
|
1437
|
-
if (!value || value.startsWith("--"))
|
|
1438
|
-
throw new Error(`${flag} requires a value`);
|
|
1439
|
-
return value;
|
|
1440
|
-
}
|
|
1441
|
-
async function loadConfig() {
|
|
1442
|
-
const localPath = await findProjectConfigPath(process.cwd());
|
|
1443
|
-
const paths = localPath && localPath !== GLOBAL_CONFIG_PATH
|
|
1444
|
-
? [localPath, GLOBAL_CONFIG_PATH]
|
|
1445
|
-
: [GLOBAL_CONFIG_PATH];
|
|
1446
|
-
for (const configPath of paths) {
|
|
1447
|
-
try {
|
|
1448
|
-
return JSON.parse(await readFile(configPath, "utf8"));
|
|
1449
|
-
}
|
|
1450
|
-
catch {
|
|
1451
|
-
// Try the next supported config location.
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
return null;
|
|
1455
|
-
}
|
|
1456
|
-
async function findProjectConfigPath(startDir) {
|
|
1457
|
-
let currentDir = startDir;
|
|
1458
|
-
while (true) {
|
|
1459
|
-
const candidate = join(currentDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE);
|
|
1460
|
-
try {
|
|
1461
|
-
await readFile(candidate, "utf8");
|
|
1462
|
-
return candidate;
|
|
1463
|
-
}
|
|
1464
|
-
catch {
|
|
1465
|
-
const parentDir = dirname(currentDir);
|
|
1466
|
-
if (parentDir === currentDir)
|
|
1467
|
-
return null;
|
|
1468
|
-
currentDir = parentDir;
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
async function saveConfig(config) {
|
|
1473
|
-
const configDir = join(process.cwd(), PROJECT_CONFIG_DIR);
|
|
1474
|
-
const configPath = join(configDir, PROJECT_CONFIG_FILE);
|
|
1475
|
-
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1476
|
-
await chmod(configDir, 0o700).catch(() => undefined);
|
|
1477
|
-
await ensureProjectConfigIgnored(configDir);
|
|
1478
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
1479
|
-
await chmod(configPath, 0o600).catch(() => undefined);
|
|
1480
|
-
return configPath;
|
|
1481
|
-
}
|
|
1482
|
-
async function ensureProjectConfigIgnored(configDir) {
|
|
1483
|
-
const ignorePath = join(configDir, ".gitignore");
|
|
1484
|
-
try {
|
|
1485
|
-
const existing = await readFile(ignorePath, "utf8");
|
|
1486
|
-
const rules = existing.split(/\r?\n/).map((line) => line.trim());
|
|
1487
|
-
if (rules.includes("*") || rules.includes(PROJECT_CONFIG_FILE) || rules.includes(`/${PROJECT_CONFIG_FILE}`))
|
|
1488
|
-
return;
|
|
1489
|
-
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
1490
|
-
await writeFile(ignorePath, `${existing}${separator}${PROJECT_CONFIG_FILE}\n`, { encoding: "utf8", mode: 0o600 });
|
|
1491
|
-
}
|
|
1492
|
-
catch {
|
|
1493
|
-
await writeFile(ignorePath, "*\n", { encoding: "utf8", mode: 0o600 });
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
async function readObjectFile(filePath, label) {
|
|
1497
|
-
const text = await readFile(filePath, "utf8");
|
|
1498
|
-
const parsed = filePath.endsWith(".json") ? JSON.parse(text) : YAML.parse(text);
|
|
1499
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1500
|
-
throw new Error(`${label} file must be a JSON/YAML object`);
|
|
1501
|
-
}
|
|
1502
|
-
return parsed;
|
|
1503
|
-
}
|
|
1504
|
-
function latestScriptVersionNames(value) {
|
|
1505
|
-
if (!Array.isArray(value))
|
|
1506
|
-
return [];
|
|
1507
|
-
const names = new Set();
|
|
1508
|
-
for (const item of value) {
|
|
1509
|
-
const body = objectValue(item);
|
|
1510
|
-
const name = optionalString(body?.name);
|
|
1511
|
-
if (name && !names.has(name))
|
|
1512
|
-
names.add(sanitizeRemoteFileName(name));
|
|
1513
|
-
}
|
|
1514
|
-
return [...names];
|
|
1515
|
-
}
|
|
1516
|
-
function objectValue(value) {
|
|
1517
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1518
|
-
}
|
|
1519
|
-
function validateExperimentRunVariantScripts(value) {
|
|
1520
|
-
if (!Array.isArray(value))
|
|
1521
|
-
throw new Error("experiment run requires variants array");
|
|
1522
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
1523
|
-
const variant = objectValue(value[index]);
|
|
1524
|
-
if (!variant)
|
|
1525
|
-
throw new Error(`variants[${index}] must be an object`);
|
|
1526
|
-
if (!Object.prototype.hasOwnProperty.call(variant, "script"))
|
|
1527
|
-
continue;
|
|
1528
|
-
const script = objectValue(variant.script);
|
|
1529
|
-
if (!script || !optionalString(script.versionId)) {
|
|
1530
|
-
throw new Error(`variants[${index}].script requires versionId; omit script for a config-only variant`);
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
function printExperimentRunVariantScripts(value) {
|
|
1535
|
-
const run = objectValue(objectValue(value)?.run);
|
|
1536
|
-
if (!run || !Array.isArray(run.variants))
|
|
1537
|
-
return;
|
|
1538
|
-
console.log("\nVariant execution:");
|
|
1539
|
-
for (const item of run.variants) {
|
|
1540
|
-
const variant = objectValue(item);
|
|
1541
|
-
if (!variant)
|
|
1542
|
-
continue;
|
|
1543
|
-
const variantId = optionalString(variant.variantId) ?? "(unknown)";
|
|
1544
|
-
const versionId = optionalString(objectValue(variant.script)?.versionId);
|
|
1545
|
-
console.log(` ${variantId}: ${versionId ? `script ${versionId}` : "config-only"}`);
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
async function readJsonFile(filePath) {
|
|
1549
|
-
return JSON.parse(await readFile(filePath, "utf8"));
|
|
1550
|
-
}
|
|
1551
|
-
async function writeTextFile(path, content) {
|
|
1552
|
-
await mkdir(dirname(path), { recursive: true });
|
|
1553
|
-
await writeFile(path, content, "utf8");
|
|
1554
|
-
}
|
|
1555
|
-
function sanitizeRemoteFileName(name) {
|
|
1556
|
-
const trimmed = name.trim();
|
|
1557
|
-
if (!trimmed || trimmed !== basename(trimmed) || trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
|
|
1558
|
-
throw new Error(`invalid remote file name: ${name}`);
|
|
1559
|
-
}
|
|
1560
|
-
if (!FILE_NAME_PATTERN.test(trimmed)) {
|
|
1561
|
-
throw new Error(`invalid remote file name: ${name}`);
|
|
1562
|
-
}
|
|
1563
|
-
return trimmed;
|
|
1564
|
-
}
|
|
1565
|
-
function validateJsonText(content, filePath) {
|
|
1566
|
-
try {
|
|
1567
|
-
JSON.parse(content);
|
|
1568
|
-
}
|
|
1569
|
-
catch (error) {
|
|
1570
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1571
|
-
throw new Error(`invalid JSON in ${filePath}: ${message}`);
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
function parseJsonResponse(text) {
|
|
1575
|
-
if (!text)
|
|
1576
|
-
return null;
|
|
1577
|
-
try {
|
|
1578
|
-
return JSON.parse(text);
|
|
1579
|
-
}
|
|
1580
|
-
catch {
|
|
1581
|
-
return undefined;
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
function formatHttpError(status, statusText, text, json) {
|
|
1585
|
-
if (json && typeof json === "object") {
|
|
1586
|
-
const body = json;
|
|
1587
|
-
const message = body.message || body.error;
|
|
1588
|
-
if (message)
|
|
1589
|
-
return `HTTP ${status}: ${String(message)}`;
|
|
1590
|
-
}
|
|
1591
|
-
const bodyText = truncateBody(text);
|
|
1592
|
-
return bodyText ? `HTTP ${status} ${statusText}: ${bodyText}` : `HTTP ${status} ${statusText}`;
|
|
1593
|
-
}
|
|
1594
|
-
function truncateBody(text) {
|
|
1595
|
-
const normalized = text.replace(/\s+/g, " ").trim();
|
|
1596
|
-
return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized;
|
|
1597
|
-
}
|
|
1598
|
-
function isTimeoutError(error) {
|
|
1599
|
-
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
1600
|
-
}
|
|
1601
|
-
function isInteractive() {
|
|
1602
|
-
return Boolean(input.isTTY && output.isTTY);
|
|
1603
|
-
}
|
|
1604
|
-
function reportTimeoutMs(flags) {
|
|
1605
|
-
if (flags["timeout-ms"] !== undefined)
|
|
1606
|
-
return Math.ceil(positiveNumberFlag(flags["timeout-ms"], "--timeout-ms"));
|
|
1607
|
-
if (flags.timeout !== undefined)
|
|
1608
|
-
return Math.ceil(positiveNumberFlag(flags.timeout, "--timeout") * 1000);
|
|
1609
|
-
return undefined;
|
|
1610
|
-
}
|
|
1611
|
-
function positiveNumberFlag(value, flag) {
|
|
1612
|
-
if (typeof value === "boolean")
|
|
1613
|
-
throw new Error(`${flag} requires a value`);
|
|
1614
|
-
const parsed = Number(value);
|
|
1615
|
-
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
1616
|
-
throw new Error(`${flag} must be a positive number`);
|
|
1617
|
-
return parsed;
|
|
1618
|
-
}
|
|
1619
|
-
function optionalNonNegativeInteger(value, field) {
|
|
1620
|
-
if (value === undefined || value === null || value === "")
|
|
1621
|
-
return undefined;
|
|
1622
|
-
if (typeof value === "boolean")
|
|
1623
|
-
throw new Error(`${field} must be a non-negative integer`);
|
|
1624
|
-
const parsed = Number(value);
|
|
1625
|
-
if (!Number.isInteger(parsed) || parsed < 0)
|
|
1626
|
-
throw new Error(`${field} must be a non-negative integer`);
|
|
1627
|
-
return parsed;
|
|
1628
|
-
}
|
|
1629
|
-
function positiveInteger(value, field) {
|
|
1630
|
-
const parsed = optionalNonNegativeInteger(value, field);
|
|
1631
|
-
if (!parsed)
|
|
1632
|
-
throw new Error(`${field} must be a positive integer`);
|
|
1633
|
-
return parsed;
|
|
1634
|
-
}
|
|
1635
|
-
function startProgress(label, timeoutMs) {
|
|
1636
|
-
const startedAt = Date.now();
|
|
1637
|
-
process.stderr.write(`${label}${timeoutMs ? `, timeout ${formatDuration(timeoutMs)}` : ""}...\n`);
|
|
1638
|
-
const timer = setInterval(() => {
|
|
1639
|
-
process.stderr.write(`${label} still running, elapsed ${formatDuration(Date.now() - startedAt)}...\n`);
|
|
1640
|
-
}, 5000);
|
|
1641
|
-
return () => clearInterval(timer);
|
|
1642
|
-
}
|
|
1643
|
-
function withCliMeta(value, meta) {
|
|
1644
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1645
|
-
return value;
|
|
1646
|
-
return {
|
|
1647
|
-
...value,
|
|
1648
|
-
cli: {
|
|
1649
|
-
elapsedMs: meta.elapsedMs,
|
|
1650
|
-
...(meta.timeoutMs ? { timeoutMs: meta.timeoutMs } : {}),
|
|
1651
|
-
},
|
|
1652
|
-
};
|
|
1653
|
-
}
|
|
1654
|
-
function formatDuration(ms) {
|
|
1655
|
-
if (ms < 1000)
|
|
1656
|
-
return `${ms}ms`;
|
|
1657
|
-
return `${(ms / 1000).toFixed(1)}s`;
|
|
1658
|
-
}
|
|
1659
|
-
async function printResult(value, global) {
|
|
1660
|
-
if (global.json) {
|
|
1661
|
-
console.log(JSON.stringify(value, null, 2));
|
|
1662
|
-
return;
|
|
1663
|
-
}
|
|
1664
|
-
if (typeof value === "string") {
|
|
1665
|
-
console.log(value);
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
console.log(YAML.stringify(value).trimEnd());
|
|
1669
|
-
}
|
|
1670
|
-
async function confirm(question) {
|
|
1671
|
-
const rl = createInterface({ input, output });
|
|
1672
|
-
try {
|
|
1673
|
-
const answer = await rl.question(`${question} [y/N] `);
|
|
1674
|
-
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
1675
|
-
}
|
|
1676
|
-
finally {
|
|
1677
|
-
rl.close();
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
function normalizeHost(value) {
|
|
1681
|
-
return value.trim().replace(/\/+$/, "");
|
|
1682
|
-
}
|
|
1683
|
-
function optionalString(value) {
|
|
1684
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1685
|
-
}
|
|
1686
|
-
function isScriptFileName(name) {
|
|
1687
|
-
const dot = name.lastIndexOf(".");
|
|
1688
|
-
return dot >= 0 && SCRIPT_EXTENSIONS.has(name.slice(dot).toLowerCase());
|
|
1689
|
-
}
|
|
1690
|
-
function contentTypeForFileName(name) {
|
|
1691
|
-
if (name.endsWith(".json"))
|
|
1692
|
-
return "application/json; charset=utf-8";
|
|
1693
|
-
if (name.endsWith(".lua"))
|
|
1694
|
-
return "application/lua; charset=utf-8";
|
|
1695
|
-
if (name.endsWith(".js"))
|
|
1696
|
-
return "text/javascript; charset=utf-8";
|
|
1697
|
-
return "text/plain; charset=utf-8";
|
|
1698
|
-
}
|
|
1699
|
-
async function loadPackageMetadata() {
|
|
1700
|
-
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
1701
|
-
const name = typeof packageJson.name === "string" ? packageJson.name : "gamealgo-cli";
|
|
1702
|
-
if (typeof packageJson.version !== "string" || !packageJson.version) {
|
|
1703
|
-
throw new Error("CLI package version is missing");
|
|
1704
|
-
}
|
|
1705
|
-
return { name, version: packageJson.version };
|
|
1706
|
-
}
|
|
1707
|
-
function printHelp() {
|
|
1708
|
-
console.log(`
|
|
1709
|
-
GameAlgo CLI
|
|
1710
|
-
|
|
1711
|
-
Usage:
|
|
1712
|
-
gamealgo --version
|
|
1713
|
-
gamealgo login --host <admin-url> --admin-key <game-admin-key>
|
|
1714
|
-
gamealgo whoami
|
|
1715
|
-
|
|
1716
|
-
gamealgo docs
|
|
1717
|
-
gamealgo docs agent-onboarding
|
|
1718
|
-
gamealgo docs report-packs --json
|
|
1719
|
-
gamealgo docs experiment-integration-versions --open
|
|
1720
|
-
gamealgo docs --host <admin-url>
|
|
1721
|
-
|
|
1722
|
-
gamealgo experiment strategies
|
|
1723
|
-
gamealgo experiment strategies --include-archived
|
|
1724
|
-
gamealgo experiment integration-version latest
|
|
1725
|
-
gamealgo experiment integration-version list
|
|
1726
|
-
gamealgo experiment integration-version get --version 3
|
|
1727
|
-
gamealgo experiment integration-version create --title "关卡动态难度" --message "客户端已接入难度参数"
|
|
1728
|
-
gamealgo experiment integration-version update --version 3 --message "补充支持新参数"
|
|
1729
|
-
gamealgo experiment strategy show ad_frequency
|
|
1730
|
-
gamealgo experiment strategy publish strategy.yaml --yes
|
|
1731
|
-
gamealgo experiment strategy default ad_frequency strategy.yaml --yes
|
|
1732
|
-
gamealgo experiment strategy archive ad_frequency --yes
|
|
1733
|
-
gamealgo experiment strategy rollback ad_frequency --version-id xv_xxxxxxxxxxxxxxxx --message "回滚参数" --yes
|
|
1734
|
-
gamealgo experiment run create ad_frequency run.yaml [--manual] --yes
|
|
1735
|
-
gamealgo experiment run show xrun_xxxxxxxxxxxxxxxx
|
|
1736
|
-
gamealgo experiment run status xrun_xxxxxxxxxxxxxxxx
|
|
1737
|
-
gamealgo experiment run evaluate xrun_xxxxxxxxxxxxxxxx --from 2026-07-01 --to 2026-07-07 --yes
|
|
1738
|
-
gamealgo experiment run report xrun_xxxxxxxxxxxxxxxx --round 1
|
|
1739
|
-
gamealgo experiment run objective xrun_xxxxxxxxxxxxxxxx --objective active_days@1 --message "改为关注活跃天数" --yes
|
|
1740
|
-
gamealgo experiment run promote xrun_xxxxxxxxxxxxxxxx --variant bravo --message "采用胜出组" --yes
|
|
1741
|
-
gamealgo experiment run cancel xrun_xxxxxxxxxxxxxxxx --message "停止实验" --yes
|
|
1742
|
-
gamealgo experiment override set xrun_xxxxxxxxxxxxxxxx --user user-1 --variant bravo --yes
|
|
1743
|
-
gamealgo experiment override list xrun_xxxxxxxxxxxxxxxx
|
|
1744
|
-
|
|
1745
|
-
gamealgo key list
|
|
1746
|
-
gamealgo key create --name tapmaker-proxy
|
|
1747
|
-
gamealgo key reveal --name tapmaker-proxy
|
|
1748
|
-
gamealgo key revoke --name tapmaker-proxy --yes
|
|
1749
|
-
|
|
1750
|
-
gamealgo script list
|
|
1751
|
-
gamealgo script versions level-generator.js
|
|
1752
|
-
gamealgo script pull level-generator.js --out scripts/
|
|
1753
|
-
gamealgo script pull --all --out scripts/
|
|
1754
|
-
gamealgo script publish scripts/level-generator.js --message "initial version"
|
|
1755
|
-
|
|
1756
|
-
gamealgo config list
|
|
1757
|
-
gamealgo config pull gameplay.json --out configs/
|
|
1758
|
-
gamealgo config pull --all --out configs/
|
|
1759
|
-
gamealgo config publish configs/gameplay.json
|
|
1760
|
-
|
|
1761
|
-
gamealgo report pull --out gamealgo-report-pack-v1.json
|
|
1762
|
-
gamealgo report validate gamealgo-report-pack-v1.json
|
|
1763
|
-
gamealgo report publish gamealgo-report-pack-v1.json
|
|
1764
|
-
gamealgo report manifest
|
|
1765
|
-
gamealgo report result --from 2026-06-14 --to 2026-06-21 --tab Revenue --group "Daily ARPU" --selector experiment=ad_frequency --timeout 60 --out report-result.json
|
|
1766
|
-
gamealgo report preview --pack gamealgo-report-pack-v1.json --from 2026-06-14 --to 2026-06-21 --group "Daily ARPU" --timeout 60 --out preview-result.json
|
|
1767
|
-
|
|
1768
|
-
gamealgo events count --from 2026-06-23 --to 2026-06-23
|
|
1769
|
-
gamealgo events count --from 2026-06-23 --to 2026-06-23 --event-type level_end
|
|
1770
|
-
|
|
1771
|
-
gamealgo marketing adjust get
|
|
1772
|
-
gamealgo marketing adjust configure --api-token <token> --app-token <app-token> --platform ios --currency USD
|
|
1773
|
-
gamealgo marketing adjust sync --from 2026-06-01 --to 2026-06-07 --timeout 60
|
|
1774
|
-
|
|
1775
|
-
Internal bootstrap:
|
|
1776
|
-
gamealgo admin-key create --host <admin-url> --admin-token <root-token> --game Mahjong --name ai-agent
|
|
1777
|
-
`.trim());
|
|
1778
|
-
}
|
|
1779
|
-
await main();
|