@gpc-cli/core 0.1.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/dist/index.d.ts +456 -0
- package/dist/index.js +2118 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2118 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var GpcError = class extends Error {
|
|
3
|
+
constructor(message, code, exitCode, suggestion) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.exitCode = exitCode;
|
|
7
|
+
this.suggestion = suggestion;
|
|
8
|
+
this.name = "GpcError";
|
|
9
|
+
}
|
|
10
|
+
toJSON() {
|
|
11
|
+
return {
|
|
12
|
+
success: false,
|
|
13
|
+
error: {
|
|
14
|
+
code: this.code,
|
|
15
|
+
message: this.message,
|
|
16
|
+
suggestion: this.suggestion
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var ConfigError = class extends GpcError {
|
|
22
|
+
constructor(message, code, suggestion) {
|
|
23
|
+
super(message, code, 1, suggestion);
|
|
24
|
+
this.name = "ConfigError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var ApiError = class extends GpcError {
|
|
28
|
+
constructor(message, code, statusCode, suggestion) {
|
|
29
|
+
super(message, code, 4, suggestion);
|
|
30
|
+
this.statusCode = statusCode;
|
|
31
|
+
this.name = "ApiError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var NetworkError = class extends GpcError {
|
|
35
|
+
constructor(message, suggestion) {
|
|
36
|
+
super(message, "NETWORK_ERROR", 5, suggestion);
|
|
37
|
+
this.name = "NetworkError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/output.ts
|
|
42
|
+
import process from "process";
|
|
43
|
+
function detectOutputFormat() {
|
|
44
|
+
return process.stdout.isTTY ? "table" : "json";
|
|
45
|
+
}
|
|
46
|
+
function formatOutput(data, format, redact = true) {
|
|
47
|
+
const safe = redact ? redactSensitive(data) : data;
|
|
48
|
+
switch (format) {
|
|
49
|
+
case "json":
|
|
50
|
+
return formatJson(safe);
|
|
51
|
+
case "yaml":
|
|
52
|
+
return formatYaml(safe);
|
|
53
|
+
case "markdown":
|
|
54
|
+
return formatMarkdown(safe);
|
|
55
|
+
case "table":
|
|
56
|
+
return formatTable(safe);
|
|
57
|
+
default:
|
|
58
|
+
return formatJson(safe);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
62
|
+
"private_key",
|
|
63
|
+
"privateKey",
|
|
64
|
+
"private_key_id",
|
|
65
|
+
"privateKeyId",
|
|
66
|
+
"accessToken",
|
|
67
|
+
"access_token",
|
|
68
|
+
"refreshToken",
|
|
69
|
+
"refresh_token",
|
|
70
|
+
"client_secret",
|
|
71
|
+
"clientSecret",
|
|
72
|
+
"token",
|
|
73
|
+
"password",
|
|
74
|
+
"secret",
|
|
75
|
+
"credentials"
|
|
76
|
+
]);
|
|
77
|
+
var REDACTED = "[REDACTED]";
|
|
78
|
+
function redactSensitive(data) {
|
|
79
|
+
if (data === null || data === void 0) return data;
|
|
80
|
+
if (typeof data === "string") return data;
|
|
81
|
+
if (typeof data === "number" || typeof data === "boolean") return data;
|
|
82
|
+
if (Array.isArray(data)) {
|
|
83
|
+
return data.map((item) => redactSensitive(item));
|
|
84
|
+
}
|
|
85
|
+
if (typeof data === "object") {
|
|
86
|
+
const result = {};
|
|
87
|
+
for (const [key, value] of Object.entries(data)) {
|
|
88
|
+
if (SENSITIVE_KEYS.has(key) && typeof value === "string") {
|
|
89
|
+
result[key] = REDACTED;
|
|
90
|
+
} else {
|
|
91
|
+
result[key] = redactSensitive(value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
return data;
|
|
97
|
+
}
|
|
98
|
+
function formatJson(data) {
|
|
99
|
+
return JSON.stringify(data, null, 2);
|
|
100
|
+
}
|
|
101
|
+
function formatYaml(data, indent = 0) {
|
|
102
|
+
if (data === null || data === void 0) {
|
|
103
|
+
return "null";
|
|
104
|
+
}
|
|
105
|
+
if (typeof data === "string") {
|
|
106
|
+
return data.includes("\n") ? `|
|
|
107
|
+
${data.split("\n").map((l) => `${" ".repeat(indent + 1)}${l}`).join("\n")}` : data;
|
|
108
|
+
}
|
|
109
|
+
if (typeof data === "number" || typeof data === "boolean") {
|
|
110
|
+
return String(data);
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(data)) {
|
|
113
|
+
if (data.length === 0) return "[]";
|
|
114
|
+
return data.map((item) => {
|
|
115
|
+
const value = formatYaml(item, indent + 1);
|
|
116
|
+
const prefix = `${" ".repeat(indent)}- `;
|
|
117
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
118
|
+
const lines = value.split("\n");
|
|
119
|
+
return `${prefix}${lines[0]}
|
|
120
|
+
${lines.slice(1).map((l) => `${" ".repeat(indent)} ${l}`).join("\n")}`;
|
|
121
|
+
}
|
|
122
|
+
return `${prefix}${value}`;
|
|
123
|
+
}).join("\n");
|
|
124
|
+
}
|
|
125
|
+
if (typeof data === "object") {
|
|
126
|
+
const entries = Object.entries(data);
|
|
127
|
+
if (entries.length === 0) return "{}";
|
|
128
|
+
return entries.map(([key, value]) => {
|
|
129
|
+
if (typeof value === "object" && value !== null) {
|
|
130
|
+
return `${" ".repeat(indent)}${key}:
|
|
131
|
+
${formatYaml(value, indent + 1)}`;
|
|
132
|
+
}
|
|
133
|
+
return `${" ".repeat(indent)}${key}: ${formatYaml(value, indent)}`;
|
|
134
|
+
}).join("\n");
|
|
135
|
+
}
|
|
136
|
+
return String(data);
|
|
137
|
+
}
|
|
138
|
+
function formatTable(data) {
|
|
139
|
+
const rows = toRows(data);
|
|
140
|
+
if (rows.length === 0) return "";
|
|
141
|
+
const keys = Object.keys(rows[0]);
|
|
142
|
+
if (keys.length === 0) return "";
|
|
143
|
+
const widths = keys.map(
|
|
144
|
+
(key) => Math.max(key.length, ...rows.map((row) => String(row[key] ?? "").length))
|
|
145
|
+
);
|
|
146
|
+
const header = keys.map((key, i) => key.padEnd(widths[i])).join(" ");
|
|
147
|
+
const separator = widths.map((w) => "-".repeat(w)).join(" ");
|
|
148
|
+
const body = rows.map(
|
|
149
|
+
(row) => keys.map((key, i) => String(row[key] ?? "").padEnd(widths[i])).join(" ")
|
|
150
|
+
).join("\n");
|
|
151
|
+
return `${header}
|
|
152
|
+
${separator}
|
|
153
|
+
${body}`;
|
|
154
|
+
}
|
|
155
|
+
function formatMarkdown(data) {
|
|
156
|
+
const rows = toRows(data);
|
|
157
|
+
if (rows.length === 0) return "";
|
|
158
|
+
const keys = Object.keys(rows[0]);
|
|
159
|
+
if (keys.length === 0) return "";
|
|
160
|
+
const widths = keys.map(
|
|
161
|
+
(key) => Math.max(key.length, ...rows.map((row) => String(row[key] ?? "").length))
|
|
162
|
+
);
|
|
163
|
+
const header = `| ${keys.map((key, i) => key.padEnd(widths[i])).join(" | ")} |`;
|
|
164
|
+
const separator = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
|
|
165
|
+
const body = rows.map(
|
|
166
|
+
(row) => `| ${keys.map((key, i) => String(row[key] ?? "").padEnd(widths[i])).join(" | ")} |`
|
|
167
|
+
).join("\n");
|
|
168
|
+
return `${header}
|
|
169
|
+
${separator}
|
|
170
|
+
${body}`;
|
|
171
|
+
}
|
|
172
|
+
function toRows(data) {
|
|
173
|
+
if (Array.isArray(data)) {
|
|
174
|
+
return data.filter(
|
|
175
|
+
(item) => typeof item === "object" && item !== null
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (typeof data === "object" && data !== null) {
|
|
179
|
+
return [data];
|
|
180
|
+
}
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/plugins.ts
|
|
185
|
+
var PluginManager = class {
|
|
186
|
+
plugins = [];
|
|
187
|
+
beforeHandlers = [];
|
|
188
|
+
afterHandlers = [];
|
|
189
|
+
errorHandlers = [];
|
|
190
|
+
beforeRequestHandlers = [];
|
|
191
|
+
afterResponseHandlers = [];
|
|
192
|
+
registeredCommands = [];
|
|
193
|
+
/** Load and register a plugin */
|
|
194
|
+
async load(plugin, manifest) {
|
|
195
|
+
const isTrusted = manifest?.trusted ?? plugin.name.startsWith("@gpc-cli/");
|
|
196
|
+
if (!isTrusted && manifest?.permissions) {
|
|
197
|
+
validatePermissions(manifest.permissions);
|
|
198
|
+
}
|
|
199
|
+
const hooks = createHooks(
|
|
200
|
+
this.beforeHandlers,
|
|
201
|
+
this.afterHandlers,
|
|
202
|
+
this.errorHandlers,
|
|
203
|
+
this.beforeRequestHandlers,
|
|
204
|
+
this.afterResponseHandlers,
|
|
205
|
+
this.registeredCommands
|
|
206
|
+
);
|
|
207
|
+
await plugin.register(hooks);
|
|
208
|
+
this.plugins.push({
|
|
209
|
+
name: plugin.name,
|
|
210
|
+
version: plugin.version,
|
|
211
|
+
trusted: isTrusted
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/** Run all beforeCommand handlers */
|
|
215
|
+
async runBeforeCommand(event) {
|
|
216
|
+
for (const handler of this.beforeHandlers) {
|
|
217
|
+
await handler(event);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Run all afterCommand handlers */
|
|
221
|
+
async runAfterCommand(event, result) {
|
|
222
|
+
for (const handler of this.afterHandlers) {
|
|
223
|
+
await handler(event, result);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Run all onError handlers */
|
|
227
|
+
async runOnError(event, error) {
|
|
228
|
+
for (const handler of this.errorHandlers) {
|
|
229
|
+
try {
|
|
230
|
+
await handler(event, error);
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Run all beforeRequest handlers */
|
|
236
|
+
async runBeforeRequest(event) {
|
|
237
|
+
for (const handler of this.beforeRequestHandlers) {
|
|
238
|
+
try {
|
|
239
|
+
await handler(event);
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** Run all afterResponse handlers */
|
|
245
|
+
async runAfterResponse(event, response) {
|
|
246
|
+
for (const handler of this.afterResponseHandlers) {
|
|
247
|
+
try {
|
|
248
|
+
await handler(event, response);
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Get commands registered by plugins */
|
|
254
|
+
getRegisteredCommands() {
|
|
255
|
+
return [...this.registeredCommands];
|
|
256
|
+
}
|
|
257
|
+
/** Get list of loaded plugins */
|
|
258
|
+
getLoadedPlugins() {
|
|
259
|
+
return [...this.plugins];
|
|
260
|
+
}
|
|
261
|
+
/** Whether any request/response hooks are registered */
|
|
262
|
+
hasRequestHooks() {
|
|
263
|
+
return this.beforeRequestHandlers.length > 0 || this.afterResponseHandlers.length > 0;
|
|
264
|
+
}
|
|
265
|
+
/** Reset (for testing) */
|
|
266
|
+
reset() {
|
|
267
|
+
this.plugins = [];
|
|
268
|
+
this.beforeHandlers = [];
|
|
269
|
+
this.afterHandlers = [];
|
|
270
|
+
this.errorHandlers = [];
|
|
271
|
+
this.beforeRequestHandlers = [];
|
|
272
|
+
this.afterResponseHandlers = [];
|
|
273
|
+
this.registeredCommands = [];
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
function createHooks(beforeHandlers, afterHandlers, errorHandlers, beforeRequestHandlers, afterResponseHandlers, registeredCommands) {
|
|
277
|
+
return {
|
|
278
|
+
beforeCommand(handler) {
|
|
279
|
+
beforeHandlers.push(handler);
|
|
280
|
+
},
|
|
281
|
+
afterCommand(handler) {
|
|
282
|
+
afterHandlers.push(handler);
|
|
283
|
+
},
|
|
284
|
+
onError(handler) {
|
|
285
|
+
errorHandlers.push(handler);
|
|
286
|
+
},
|
|
287
|
+
beforeRequest(handler) {
|
|
288
|
+
beforeRequestHandlers.push(handler);
|
|
289
|
+
},
|
|
290
|
+
afterResponse(handler) {
|
|
291
|
+
afterResponseHandlers.push(handler);
|
|
292
|
+
},
|
|
293
|
+
registerCommands(registrar) {
|
|
294
|
+
const registry = {
|
|
295
|
+
add(cmd) {
|
|
296
|
+
registeredCommands.push(cmd);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
registrar(registry);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
var VALID_PERMISSIONS = /* @__PURE__ */ new Set([
|
|
304
|
+
"read:config",
|
|
305
|
+
"write:config",
|
|
306
|
+
"read:auth",
|
|
307
|
+
"api:read",
|
|
308
|
+
"api:write",
|
|
309
|
+
"commands:register",
|
|
310
|
+
"hooks:beforeCommand",
|
|
311
|
+
"hooks:afterCommand",
|
|
312
|
+
"hooks:onError",
|
|
313
|
+
"hooks:beforeRequest",
|
|
314
|
+
"hooks:afterResponse"
|
|
315
|
+
]);
|
|
316
|
+
function validatePermissions(permissions) {
|
|
317
|
+
for (const perm of permissions) {
|
|
318
|
+
if (!VALID_PERMISSIONS.has(perm)) {
|
|
319
|
+
throw new GpcError(
|
|
320
|
+
`Unknown plugin permission: "${perm}"`,
|
|
321
|
+
"PLUGIN_INVALID_PERMISSION",
|
|
322
|
+
10,
|
|
323
|
+
`Valid permissions: ${[...VALID_PERMISSIONS].join(", ")}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function discoverPlugins(options) {
|
|
329
|
+
const plugins = [];
|
|
330
|
+
const seen = /* @__PURE__ */ new Set();
|
|
331
|
+
if (options?.configPlugins) {
|
|
332
|
+
for (const name of options.configPlugins) {
|
|
333
|
+
if (seen.has(name)) continue;
|
|
334
|
+
try {
|
|
335
|
+
const mod = await import(name);
|
|
336
|
+
const plugin = resolvePlugin(mod);
|
|
337
|
+
if (plugin) {
|
|
338
|
+
plugins.push(plugin);
|
|
339
|
+
seen.add(name);
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return plugins;
|
|
346
|
+
}
|
|
347
|
+
function resolvePlugin(mod) {
|
|
348
|
+
if (!mod || typeof mod !== "object") return void 0;
|
|
349
|
+
const m = mod;
|
|
350
|
+
if (isPlugin(m["default"])) return m["default"];
|
|
351
|
+
if (isPlugin(m["plugin"])) return m["plugin"];
|
|
352
|
+
if (isPlugin(m)) return m;
|
|
353
|
+
return void 0;
|
|
354
|
+
}
|
|
355
|
+
function isPlugin(obj) {
|
|
356
|
+
if (!obj || typeof obj !== "object") return false;
|
|
357
|
+
const p = obj;
|
|
358
|
+
return typeof p["name"] === "string" && typeof p["version"] === "string" && typeof p["register"] === "function";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/commands/apps.ts
|
|
362
|
+
async function getAppInfo(client, packageName) {
|
|
363
|
+
const edit = await client.edits.insert(packageName);
|
|
364
|
+
try {
|
|
365
|
+
const details = await client.details.get(packageName, edit.id);
|
|
366
|
+
await client.edits.delete(packageName, edit.id);
|
|
367
|
+
return {
|
|
368
|
+
packageName,
|
|
369
|
+
title: details.title,
|
|
370
|
+
defaultLanguage: details.defaultLanguage,
|
|
371
|
+
contactEmail: details.contactEmail
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
375
|
+
});
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/utils/file-validation.ts
|
|
381
|
+
import { readFile, stat } from "fs/promises";
|
|
382
|
+
import { extname } from "path";
|
|
383
|
+
var ZIP_MAGIC = Buffer.from([80, 75, 3, 4]);
|
|
384
|
+
var MAX_APK_SIZE = 150 * 1024 * 1024;
|
|
385
|
+
var MAX_AAB_SIZE = 500 * 1024 * 1024;
|
|
386
|
+
var LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
|
|
387
|
+
async function validateUploadFile(filePath) {
|
|
388
|
+
const errors = [];
|
|
389
|
+
const warnings = [];
|
|
390
|
+
const ext = extname(filePath).toLowerCase();
|
|
391
|
+
let fileType = "unknown";
|
|
392
|
+
if (ext === ".aab") {
|
|
393
|
+
fileType = "aab";
|
|
394
|
+
} else if (ext === ".apk") {
|
|
395
|
+
fileType = "apk";
|
|
396
|
+
} else {
|
|
397
|
+
errors.push(`Unsupported file extension "${ext}". Expected .aab or .apk`);
|
|
398
|
+
}
|
|
399
|
+
let sizeBytes = 0;
|
|
400
|
+
try {
|
|
401
|
+
const stats = await stat(filePath);
|
|
402
|
+
sizeBytes = stats.size;
|
|
403
|
+
if (sizeBytes === 0) {
|
|
404
|
+
errors.push("File is empty (0 bytes)");
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
errors.push(`File not found: ${filePath}`);
|
|
408
|
+
return { valid: false, fileType, sizeBytes: 0, errors, warnings };
|
|
409
|
+
}
|
|
410
|
+
if (fileType === "apk" && sizeBytes > MAX_APK_SIZE) {
|
|
411
|
+
errors.push(
|
|
412
|
+
`APK exceeds 150 MB limit (${formatSize(sizeBytes)}). Consider using AAB format instead.`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (fileType === "aab" && sizeBytes > MAX_AAB_SIZE) {
|
|
416
|
+
errors.push(`AAB exceeds 500 MB limit (${formatSize(sizeBytes)}).`);
|
|
417
|
+
}
|
|
418
|
+
if (sizeBytes > LARGE_FILE_THRESHOLD && errors.length === 0) {
|
|
419
|
+
warnings.push(
|
|
420
|
+
`Large file (${formatSize(sizeBytes)}). Upload may take a while on slow connections.`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (sizeBytes > 0) {
|
|
424
|
+
try {
|
|
425
|
+
const fd = await readFile(filePath, { flag: "r" });
|
|
426
|
+
const header = fd.subarray(0, 4);
|
|
427
|
+
if (!header.equals(ZIP_MAGIC)) {
|
|
428
|
+
errors.push(
|
|
429
|
+
"File does not have valid ZIP magic bytes (PK\\x03\\x04). Both AAB and APK files must be valid ZIP archives."
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
errors.push("Unable to read file header for validation");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
valid: errors.length === 0,
|
|
438
|
+
fileType,
|
|
439
|
+
sizeBytes,
|
|
440
|
+
errors,
|
|
441
|
+
warnings
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function formatSize(bytes) {
|
|
445
|
+
if (bytes >= 1024 * 1024 * 1024) {
|
|
446
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
447
|
+
}
|
|
448
|
+
if (bytes >= 1024 * 1024) {
|
|
449
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
450
|
+
}
|
|
451
|
+
if (bytes >= 1024) {
|
|
452
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
453
|
+
}
|
|
454
|
+
return `${bytes} B`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/commands/releases.ts
|
|
458
|
+
async function uploadRelease(client, packageName, filePath, options) {
|
|
459
|
+
const validation = await validateUploadFile(filePath);
|
|
460
|
+
if (!validation.valid) {
|
|
461
|
+
throw new Error(`File validation failed:
|
|
462
|
+
${validation.errors.join("\n")}`);
|
|
463
|
+
}
|
|
464
|
+
const edit = await client.edits.insert(packageName);
|
|
465
|
+
try {
|
|
466
|
+
const bundle = await client.bundles.upload(packageName, edit.id, filePath);
|
|
467
|
+
if (options.mappingFile) {
|
|
468
|
+
await client.deobfuscation.upload(
|
|
469
|
+
packageName,
|
|
470
|
+
edit.id,
|
|
471
|
+
bundle.versionCode,
|
|
472
|
+
options.mappingFile
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const release = {
|
|
476
|
+
versionCodes: [String(bundle.versionCode)],
|
|
477
|
+
status: options.status || (options.userFraction ? "inProgress" : "completed"),
|
|
478
|
+
...options.userFraction && { userFraction: options.userFraction },
|
|
479
|
+
...options.releaseNotes && { releaseNotes: options.releaseNotes },
|
|
480
|
+
...options.releaseName && { name: options.releaseName }
|
|
481
|
+
};
|
|
482
|
+
await client.tracks.update(packageName, edit.id, options.track, release);
|
|
483
|
+
await client.edits.validate(packageName, edit.id);
|
|
484
|
+
await client.edits.commit(packageName, edit.id);
|
|
485
|
+
return {
|
|
486
|
+
versionCode: bundle.versionCode,
|
|
487
|
+
track: options.track,
|
|
488
|
+
status: release.status
|
|
489
|
+
};
|
|
490
|
+
} catch (error) {
|
|
491
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
492
|
+
});
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function getReleasesStatus(client, packageName, trackFilter) {
|
|
497
|
+
const edit = await client.edits.insert(packageName);
|
|
498
|
+
try {
|
|
499
|
+
const tracks = trackFilter ? [await client.tracks.get(packageName, edit.id, trackFilter)] : await client.tracks.list(packageName, edit.id);
|
|
500
|
+
await client.edits.delete(packageName, edit.id);
|
|
501
|
+
const results = [];
|
|
502
|
+
for (const track of tracks) {
|
|
503
|
+
for (const release of track.releases || []) {
|
|
504
|
+
results.push({
|
|
505
|
+
track: track.track,
|
|
506
|
+
status: release.status,
|
|
507
|
+
versionCodes: release.versionCodes || [],
|
|
508
|
+
userFraction: release.userFraction,
|
|
509
|
+
releaseNotes: release.releaseNotes
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return results;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
516
|
+
});
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async function promoteRelease(client, packageName, fromTrack, toTrack, options) {
|
|
521
|
+
const edit = await client.edits.insert(packageName);
|
|
522
|
+
try {
|
|
523
|
+
const sourceTrack = await client.tracks.get(packageName, edit.id, fromTrack);
|
|
524
|
+
const currentRelease = sourceTrack.releases?.find(
|
|
525
|
+
(r) => r.status === "completed" || r.status === "inProgress"
|
|
526
|
+
);
|
|
527
|
+
if (!currentRelease) {
|
|
528
|
+
throw new Error(`No active release found on track "${fromTrack}"`);
|
|
529
|
+
}
|
|
530
|
+
const release = {
|
|
531
|
+
versionCodes: currentRelease.versionCodes,
|
|
532
|
+
status: options?.userFraction ? "inProgress" : "completed",
|
|
533
|
+
...options?.userFraction && { userFraction: options.userFraction },
|
|
534
|
+
releaseNotes: options?.releaseNotes || currentRelease.releaseNotes || []
|
|
535
|
+
};
|
|
536
|
+
await client.tracks.update(packageName, edit.id, toTrack, release);
|
|
537
|
+
await client.edits.validate(packageName, edit.id);
|
|
538
|
+
await client.edits.commit(packageName, edit.id);
|
|
539
|
+
return {
|
|
540
|
+
track: toTrack,
|
|
541
|
+
status: release.status,
|
|
542
|
+
versionCodes: release.versionCodes,
|
|
543
|
+
userFraction: release.userFraction
|
|
544
|
+
};
|
|
545
|
+
} catch (error) {
|
|
546
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
547
|
+
});
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function updateRollout(client, packageName, track, action, userFraction) {
|
|
552
|
+
const edit = await client.edits.insert(packageName);
|
|
553
|
+
try {
|
|
554
|
+
const trackData = await client.tracks.get(packageName, edit.id, track);
|
|
555
|
+
const currentRelease = trackData.releases?.find(
|
|
556
|
+
(r) => r.status === "inProgress" || r.status === "halted"
|
|
557
|
+
);
|
|
558
|
+
if (!currentRelease) {
|
|
559
|
+
throw new Error(`No active rollout found on track "${track}"`);
|
|
560
|
+
}
|
|
561
|
+
let newStatus;
|
|
562
|
+
let newFraction;
|
|
563
|
+
switch (action) {
|
|
564
|
+
case "increase":
|
|
565
|
+
if (!userFraction) throw new Error("--to <percentage> is required for rollout increase");
|
|
566
|
+
newStatus = "inProgress";
|
|
567
|
+
newFraction = userFraction;
|
|
568
|
+
break;
|
|
569
|
+
case "halt":
|
|
570
|
+
newStatus = "halted";
|
|
571
|
+
newFraction = currentRelease.userFraction;
|
|
572
|
+
break;
|
|
573
|
+
case "resume":
|
|
574
|
+
newStatus = "inProgress";
|
|
575
|
+
newFraction = currentRelease.userFraction;
|
|
576
|
+
break;
|
|
577
|
+
case "complete":
|
|
578
|
+
newStatus = "completed";
|
|
579
|
+
newFraction = void 0;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
const release = {
|
|
583
|
+
versionCodes: currentRelease.versionCodes,
|
|
584
|
+
status: newStatus,
|
|
585
|
+
...newFraction !== void 0 && { userFraction: newFraction },
|
|
586
|
+
releaseNotes: currentRelease.releaseNotes || []
|
|
587
|
+
};
|
|
588
|
+
await client.tracks.update(packageName, edit.id, track, release);
|
|
589
|
+
await client.edits.validate(packageName, edit.id);
|
|
590
|
+
await client.edits.commit(packageName, edit.id);
|
|
591
|
+
return {
|
|
592
|
+
track,
|
|
593
|
+
status: newStatus,
|
|
594
|
+
versionCodes: release.versionCodes,
|
|
595
|
+
userFraction: newFraction
|
|
596
|
+
};
|
|
597
|
+
} catch (error) {
|
|
598
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
599
|
+
});
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function listTracks(client, packageName) {
|
|
604
|
+
const edit = await client.edits.insert(packageName);
|
|
605
|
+
try {
|
|
606
|
+
const tracks = await client.tracks.list(packageName, edit.id);
|
|
607
|
+
await client.edits.delete(packageName, edit.id);
|
|
608
|
+
return tracks;
|
|
609
|
+
} catch (error) {
|
|
610
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
611
|
+
});
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/utils/bcp47.ts
|
|
617
|
+
var GOOGLE_PLAY_LANGUAGES = [
|
|
618
|
+
"af",
|
|
619
|
+
"am",
|
|
620
|
+
"ar",
|
|
621
|
+
"hy-AM",
|
|
622
|
+
"az-AZ",
|
|
623
|
+
"eu-ES",
|
|
624
|
+
"be",
|
|
625
|
+
"bn-BD",
|
|
626
|
+
"bg",
|
|
627
|
+
"my-MM",
|
|
628
|
+
"ca",
|
|
629
|
+
"zh-HK",
|
|
630
|
+
"zh-CN",
|
|
631
|
+
"zh-TW",
|
|
632
|
+
"hr",
|
|
633
|
+
"cs-CZ",
|
|
634
|
+
"da-DK",
|
|
635
|
+
"nl-NL",
|
|
636
|
+
"en-AU",
|
|
637
|
+
"en-CA",
|
|
638
|
+
"en-IN",
|
|
639
|
+
"en-SG",
|
|
640
|
+
"en-GB",
|
|
641
|
+
"en-US",
|
|
642
|
+
"et",
|
|
643
|
+
"fil",
|
|
644
|
+
"fi-FI",
|
|
645
|
+
"fr-FR",
|
|
646
|
+
"fr-CA",
|
|
647
|
+
"gl-ES",
|
|
648
|
+
"ka-GE",
|
|
649
|
+
"de-DE",
|
|
650
|
+
"el-GR",
|
|
651
|
+
"gu",
|
|
652
|
+
"iw-IL",
|
|
653
|
+
"hi-IN",
|
|
654
|
+
"hu-HU",
|
|
655
|
+
"is-IS",
|
|
656
|
+
"id",
|
|
657
|
+
"it-IT",
|
|
658
|
+
"ja-JP",
|
|
659
|
+
"kn-IN",
|
|
660
|
+
"kk",
|
|
661
|
+
"km-KH",
|
|
662
|
+
"ko-KR",
|
|
663
|
+
"ky-KG",
|
|
664
|
+
"lo-LA",
|
|
665
|
+
"lv",
|
|
666
|
+
"lt",
|
|
667
|
+
"mk-MK",
|
|
668
|
+
"ms",
|
|
669
|
+
"ms-MY",
|
|
670
|
+
"ml-IN",
|
|
671
|
+
"mr-IN",
|
|
672
|
+
"mn-MN",
|
|
673
|
+
"ne-NP",
|
|
674
|
+
"no-NO",
|
|
675
|
+
"fa",
|
|
676
|
+
"pl-PL",
|
|
677
|
+
"pt-BR",
|
|
678
|
+
"pt-PT",
|
|
679
|
+
"pa",
|
|
680
|
+
"ro",
|
|
681
|
+
"rm",
|
|
682
|
+
"ru-RU",
|
|
683
|
+
"sr",
|
|
684
|
+
"si-LK",
|
|
685
|
+
"sk",
|
|
686
|
+
"sl",
|
|
687
|
+
"es-419",
|
|
688
|
+
"es-ES",
|
|
689
|
+
"es-US",
|
|
690
|
+
"sw",
|
|
691
|
+
"sv-SE",
|
|
692
|
+
"ta-IN",
|
|
693
|
+
"te-IN",
|
|
694
|
+
"th",
|
|
695
|
+
"tr-TR",
|
|
696
|
+
"uk",
|
|
697
|
+
"ur",
|
|
698
|
+
"vi",
|
|
699
|
+
"zu"
|
|
700
|
+
];
|
|
701
|
+
function isValidBcp47(tag) {
|
|
702
|
+
return GOOGLE_PLAY_LANGUAGES.includes(tag);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/utils/image-validation.ts
|
|
706
|
+
import { stat as stat2 } from "fs/promises";
|
|
707
|
+
import { extname as extname2 } from "path";
|
|
708
|
+
var IMAGE_SIZE_LIMITS = {
|
|
709
|
+
icon: { maxBytes: 1024 * 1024, label: "1 MB" },
|
|
710
|
+
featureGraphic: { maxBytes: 1024 * 1024, label: "1 MB" },
|
|
711
|
+
tvBanner: { maxBytes: 1024 * 1024, label: "1 MB" },
|
|
712
|
+
phoneScreenshots: { maxBytes: 8 * 1024 * 1024, label: "8 MB" },
|
|
713
|
+
sevenInchScreenshots: { maxBytes: 8 * 1024 * 1024, label: "8 MB" },
|
|
714
|
+
tenInchScreenshots: { maxBytes: 8 * 1024 * 1024, label: "8 MB" },
|
|
715
|
+
tvScreenshots: { maxBytes: 8 * 1024 * 1024, label: "8 MB" },
|
|
716
|
+
wearScreenshots: { maxBytes: 8 * 1024 * 1024, label: "8 MB" }
|
|
717
|
+
};
|
|
718
|
+
var VALID_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
|
|
719
|
+
var LARGE_IMAGE_THRESHOLD = 2 * 1024 * 1024;
|
|
720
|
+
async function validateImage(filePath, imageType) {
|
|
721
|
+
const errors = [];
|
|
722
|
+
const warnings = [];
|
|
723
|
+
const ext = extname2(filePath).toLowerCase();
|
|
724
|
+
if (!VALID_EXTENSIONS.has(ext)) {
|
|
725
|
+
errors.push(`Unsupported image format "${ext}". Use PNG or JPEG.`);
|
|
726
|
+
}
|
|
727
|
+
let sizeBytes = 0;
|
|
728
|
+
try {
|
|
729
|
+
const stats = await stat2(filePath);
|
|
730
|
+
sizeBytes = stats.size;
|
|
731
|
+
if (sizeBytes === 0) {
|
|
732
|
+
errors.push("Image file is empty (0 bytes)");
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
errors.push(`Image file not found: ${filePath}`);
|
|
736
|
+
return { valid: false, errors, warnings };
|
|
737
|
+
}
|
|
738
|
+
if (imageType && sizeBytes > 0) {
|
|
739
|
+
const limit = IMAGE_SIZE_LIMITS[imageType];
|
|
740
|
+
if (limit && sizeBytes > limit.maxBytes) {
|
|
741
|
+
errors.push(`Image exceeds ${limit.label} limit for ${imageType} (${formatSize2(sizeBytes)})`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (sizeBytes > LARGE_IMAGE_THRESHOLD && errors.length === 0) {
|
|
745
|
+
warnings.push(
|
|
746
|
+
`Large image (${formatSize2(sizeBytes)}). Consider optimizing for faster upload and better store performance.`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (ext === ".png" && sizeBytes > 512 * 1024) {
|
|
750
|
+
warnings.push("PNG file is over 512 KB. Consider compressing with tools like pngquant or optipng.");
|
|
751
|
+
}
|
|
752
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
753
|
+
}
|
|
754
|
+
function formatSize2(bytes) {
|
|
755
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
756
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
757
|
+
return `${bytes} B`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/utils/fastlane.ts
|
|
761
|
+
import { readFile as readFile2, writeFile, mkdir, readdir, stat as stat3 } from "fs/promises";
|
|
762
|
+
import { join } from "path";
|
|
763
|
+
var FILE_MAP = {
|
|
764
|
+
"title.txt": "title",
|
|
765
|
+
"short_description.txt": "shortDescription",
|
|
766
|
+
"full_description.txt": "fullDescription",
|
|
767
|
+
"video.txt": "video"
|
|
768
|
+
};
|
|
769
|
+
var FIELD_TO_FILE = Object.fromEntries(
|
|
770
|
+
Object.entries(FILE_MAP).map(([file, field]) => [field, file])
|
|
771
|
+
);
|
|
772
|
+
async function exists(path) {
|
|
773
|
+
try {
|
|
774
|
+
await stat3(path);
|
|
775
|
+
return true;
|
|
776
|
+
} catch {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
async function readListingsFromDir(dir) {
|
|
781
|
+
const listings = [];
|
|
782
|
+
if (!await exists(dir)) return listings;
|
|
783
|
+
const entries = await readdir(dir);
|
|
784
|
+
for (const lang of entries) {
|
|
785
|
+
const langDir = join(dir, lang);
|
|
786
|
+
const langStat = await stat3(langDir);
|
|
787
|
+
if (!langStat.isDirectory()) continue;
|
|
788
|
+
const listing = {
|
|
789
|
+
language: lang,
|
|
790
|
+
title: "",
|
|
791
|
+
shortDescription: "",
|
|
792
|
+
fullDescription: ""
|
|
793
|
+
};
|
|
794
|
+
for (const [fileName, field] of Object.entries(FILE_MAP)) {
|
|
795
|
+
const filePath = join(langDir, fileName);
|
|
796
|
+
if (await exists(filePath)) {
|
|
797
|
+
const content = await readFile2(filePath, "utf-8");
|
|
798
|
+
listing[field] = content.trimEnd();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
listings.push(listing);
|
|
802
|
+
}
|
|
803
|
+
return listings;
|
|
804
|
+
}
|
|
805
|
+
async function writeListingsToDir(dir, listings) {
|
|
806
|
+
for (const listing of listings) {
|
|
807
|
+
const langDir = join(dir, listing.language);
|
|
808
|
+
await mkdir(langDir, { recursive: true });
|
|
809
|
+
for (const [field, fileName] of Object.entries(FIELD_TO_FILE)) {
|
|
810
|
+
const value = listing[field];
|
|
811
|
+
if (value !== void 0 && value !== "") {
|
|
812
|
+
await writeFile(join(langDir, fileName), value + "\n", "utf-8");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function diffListings(local, remote) {
|
|
818
|
+
const diffs = [];
|
|
819
|
+
const remoteMap = new Map(remote.map((l) => [l.language, l]));
|
|
820
|
+
const localMap = new Map(local.map((l) => [l.language, l]));
|
|
821
|
+
for (const localListing of local) {
|
|
822
|
+
const remoteListing = remoteMap.get(localListing.language);
|
|
823
|
+
for (const [field, fileName] of Object.entries(FIELD_TO_FILE)) {
|
|
824
|
+
const localVal = (localListing[field] ?? "").toString();
|
|
825
|
+
const remoteVal = remoteListing ? (remoteListing[field] ?? "").toString() : "";
|
|
826
|
+
if (localVal !== remoteVal) {
|
|
827
|
+
diffs.push({
|
|
828
|
+
language: localListing.language,
|
|
829
|
+
field,
|
|
830
|
+
local: localVal,
|
|
831
|
+
remote: remoteVal
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
for (const remoteListing of remote) {
|
|
837
|
+
if (!localMap.has(remoteListing.language)) {
|
|
838
|
+
for (const [field] of Object.entries(FIELD_TO_FILE)) {
|
|
839
|
+
const remoteVal = (remoteListing[field] ?? "").toString();
|
|
840
|
+
if (remoteVal) {
|
|
841
|
+
diffs.push({
|
|
842
|
+
language: remoteListing.language,
|
|
843
|
+
field,
|
|
844
|
+
local: "",
|
|
845
|
+
remote: remoteVal
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return diffs;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/commands/listings.ts
|
|
855
|
+
function validateLanguage(lang) {
|
|
856
|
+
if (!isValidBcp47(lang)) {
|
|
857
|
+
throw new Error(`Invalid language tag "${lang}". Must be a valid Google Play BCP 47 code.`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
async function getListings(client, packageName, language) {
|
|
861
|
+
const edit = await client.edits.insert(packageName);
|
|
862
|
+
try {
|
|
863
|
+
let listings;
|
|
864
|
+
if (language) {
|
|
865
|
+
validateLanguage(language);
|
|
866
|
+
const listing = await client.listings.get(packageName, edit.id, language);
|
|
867
|
+
listings = [listing];
|
|
868
|
+
} else {
|
|
869
|
+
listings = await client.listings.list(packageName, edit.id);
|
|
870
|
+
}
|
|
871
|
+
await client.edits.delete(packageName, edit.id);
|
|
872
|
+
return listings;
|
|
873
|
+
} catch (error) {
|
|
874
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
875
|
+
});
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
async function updateListing(client, packageName, language, data) {
|
|
880
|
+
validateLanguage(language);
|
|
881
|
+
const edit = await client.edits.insert(packageName);
|
|
882
|
+
try {
|
|
883
|
+
const listing = await client.listings.patch(packageName, edit.id, language, data);
|
|
884
|
+
await client.edits.validate(packageName, edit.id);
|
|
885
|
+
await client.edits.commit(packageName, edit.id);
|
|
886
|
+
return listing;
|
|
887
|
+
} catch (error) {
|
|
888
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
889
|
+
});
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async function deleteListing(client, packageName, language) {
|
|
894
|
+
validateLanguage(language);
|
|
895
|
+
const edit = await client.edits.insert(packageName);
|
|
896
|
+
try {
|
|
897
|
+
await client.listings.delete(packageName, edit.id, language);
|
|
898
|
+
await client.edits.validate(packageName, edit.id);
|
|
899
|
+
await client.edits.commit(packageName, edit.id);
|
|
900
|
+
} catch (error) {
|
|
901
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
902
|
+
});
|
|
903
|
+
throw error;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async function pullListings(client, packageName, dir) {
|
|
907
|
+
const edit = await client.edits.insert(packageName);
|
|
908
|
+
try {
|
|
909
|
+
const listings = await client.listings.list(packageName, edit.id);
|
|
910
|
+
await client.edits.delete(packageName, edit.id);
|
|
911
|
+
await writeListingsToDir(dir, listings);
|
|
912
|
+
return { listings };
|
|
913
|
+
} catch (error) {
|
|
914
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
915
|
+
});
|
|
916
|
+
throw error;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
async function pushListings(client, packageName, dir, options) {
|
|
920
|
+
const localListings = await readListingsFromDir(dir);
|
|
921
|
+
if (localListings.length === 0) {
|
|
922
|
+
throw new Error(`No listings found in directory "${dir}"`);
|
|
923
|
+
}
|
|
924
|
+
for (const listing of localListings) {
|
|
925
|
+
validateLanguage(listing.language);
|
|
926
|
+
}
|
|
927
|
+
const edit = await client.edits.insert(packageName);
|
|
928
|
+
try {
|
|
929
|
+
if (options?.dryRun) {
|
|
930
|
+
const remoteListings = await client.listings.list(packageName, edit.id);
|
|
931
|
+
await client.edits.delete(packageName, edit.id);
|
|
932
|
+
const diffs = diffListings(localListings, remoteListings);
|
|
933
|
+
return { diffs };
|
|
934
|
+
}
|
|
935
|
+
for (const listing of localListings) {
|
|
936
|
+
const { language, ...data } = listing;
|
|
937
|
+
await client.listings.update(packageName, edit.id, language, data);
|
|
938
|
+
}
|
|
939
|
+
await client.edits.validate(packageName, edit.id);
|
|
940
|
+
await client.edits.commit(packageName, edit.id);
|
|
941
|
+
return {
|
|
942
|
+
updated: localListings.length,
|
|
943
|
+
languages: localListings.map((l) => l.language)
|
|
944
|
+
};
|
|
945
|
+
} catch (error) {
|
|
946
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
947
|
+
});
|
|
948
|
+
throw error;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
async function listImages(client, packageName, language, imageType) {
|
|
952
|
+
validateLanguage(language);
|
|
953
|
+
const edit = await client.edits.insert(packageName);
|
|
954
|
+
try {
|
|
955
|
+
const images = await client.images.list(packageName, edit.id, language, imageType);
|
|
956
|
+
await client.edits.delete(packageName, edit.id);
|
|
957
|
+
return images;
|
|
958
|
+
} catch (error) {
|
|
959
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
960
|
+
});
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
async function uploadImage(client, packageName, language, imageType, filePath) {
|
|
965
|
+
validateLanguage(language);
|
|
966
|
+
const imageCheck = await validateImage(filePath, imageType);
|
|
967
|
+
if (!imageCheck.valid) {
|
|
968
|
+
throw new Error(`Image validation failed: ${imageCheck.errors.join("; ")}`);
|
|
969
|
+
}
|
|
970
|
+
if (imageCheck.warnings.length > 0) {
|
|
971
|
+
for (const w of imageCheck.warnings) {
|
|
972
|
+
console.warn(`Warning: ${w}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const edit = await client.edits.insert(packageName);
|
|
976
|
+
try {
|
|
977
|
+
const image = await client.images.upload(packageName, edit.id, language, imageType, filePath);
|
|
978
|
+
await client.edits.validate(packageName, edit.id);
|
|
979
|
+
await client.edits.commit(packageName, edit.id);
|
|
980
|
+
return image;
|
|
981
|
+
} catch (error) {
|
|
982
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
983
|
+
});
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async function deleteImage(client, packageName, language, imageType, imageId) {
|
|
988
|
+
validateLanguage(language);
|
|
989
|
+
const edit = await client.edits.insert(packageName);
|
|
990
|
+
try {
|
|
991
|
+
await client.images.delete(packageName, edit.id, language, imageType, imageId);
|
|
992
|
+
await client.edits.validate(packageName, edit.id);
|
|
993
|
+
await client.edits.commit(packageName, edit.id);
|
|
994
|
+
} catch (error) {
|
|
995
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
996
|
+
});
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
async function getCountryAvailability(client, packageName, track) {
|
|
1001
|
+
const edit = await client.edits.insert(packageName);
|
|
1002
|
+
try {
|
|
1003
|
+
const availability = await client.countryAvailability.get(packageName, edit.id, track);
|
|
1004
|
+
await client.edits.delete(packageName, edit.id);
|
|
1005
|
+
return availability;
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
1008
|
+
});
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function updateAppDetails(client, packageName, details) {
|
|
1013
|
+
const edit = await client.edits.insert(packageName);
|
|
1014
|
+
try {
|
|
1015
|
+
const result = await client.details.patch(packageName, edit.id, details);
|
|
1016
|
+
await client.edits.validate(packageName, edit.id);
|
|
1017
|
+
await client.edits.commit(packageName, edit.id);
|
|
1018
|
+
return result;
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
1021
|
+
});
|
|
1022
|
+
throw error;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/utils/release-notes.ts
|
|
1027
|
+
import { readdir as readdir2, readFile as readFile3, stat as stat4 } from "fs/promises";
|
|
1028
|
+
import { extname as extname3, basename, join as join2 } from "path";
|
|
1029
|
+
var MAX_NOTES_LENGTH = 500;
|
|
1030
|
+
async function readReleaseNotesFromDir(dir) {
|
|
1031
|
+
let entries;
|
|
1032
|
+
try {
|
|
1033
|
+
entries = await readdir2(dir);
|
|
1034
|
+
} catch {
|
|
1035
|
+
throw new Error(`Release notes directory not found: ${dir}`);
|
|
1036
|
+
}
|
|
1037
|
+
const notes = [];
|
|
1038
|
+
for (const entry of entries) {
|
|
1039
|
+
if (extname3(entry) !== ".txt") continue;
|
|
1040
|
+
const language = basename(entry, ".txt");
|
|
1041
|
+
const filePath = join2(dir, entry);
|
|
1042
|
+
const stats = await stat4(filePath);
|
|
1043
|
+
if (!stats.isFile()) continue;
|
|
1044
|
+
const text = (await readFile3(filePath, "utf-8")).trim();
|
|
1045
|
+
if (text.length === 0) continue;
|
|
1046
|
+
notes.push({ language, text });
|
|
1047
|
+
}
|
|
1048
|
+
return notes;
|
|
1049
|
+
}
|
|
1050
|
+
function validateReleaseNotes(notes) {
|
|
1051
|
+
const errors = [];
|
|
1052
|
+
const warnings = [];
|
|
1053
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1054
|
+
for (const note of notes) {
|
|
1055
|
+
if (seen.has(note.language)) {
|
|
1056
|
+
errors.push(`Duplicate language code: ${note.language}`);
|
|
1057
|
+
}
|
|
1058
|
+
seen.add(note.language);
|
|
1059
|
+
if (note.text.length > MAX_NOTES_LENGTH) {
|
|
1060
|
+
errors.push(
|
|
1061
|
+
`Release notes for "${note.language}" exceed ${MAX_NOTES_LENGTH} chars (${note.text.length} chars)`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/commands/validate.ts
|
|
1069
|
+
import { stat as stat5 } from "fs/promises";
|
|
1070
|
+
var STANDARD_TRACKS = /* @__PURE__ */ new Set([
|
|
1071
|
+
"internal",
|
|
1072
|
+
"alpha",
|
|
1073
|
+
"beta",
|
|
1074
|
+
"production",
|
|
1075
|
+
// Form factor tracks
|
|
1076
|
+
"wear:internal",
|
|
1077
|
+
"wear:alpha",
|
|
1078
|
+
"wear:beta",
|
|
1079
|
+
"wear:production",
|
|
1080
|
+
"automotive:internal",
|
|
1081
|
+
"automotive:alpha",
|
|
1082
|
+
"automotive:beta",
|
|
1083
|
+
"automotive:production",
|
|
1084
|
+
"tv:internal",
|
|
1085
|
+
"tv:alpha",
|
|
1086
|
+
"tv:beta",
|
|
1087
|
+
"tv:production",
|
|
1088
|
+
"android_xr:internal",
|
|
1089
|
+
"android_xr:alpha",
|
|
1090
|
+
"android_xr:beta",
|
|
1091
|
+
"android_xr:production"
|
|
1092
|
+
]);
|
|
1093
|
+
var TRACK_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_:-]*$/;
|
|
1094
|
+
async function validatePreSubmission(options) {
|
|
1095
|
+
const checks = [];
|
|
1096
|
+
const fileResult = await validateUploadFile(options.filePath);
|
|
1097
|
+
checks.push({
|
|
1098
|
+
name: "file",
|
|
1099
|
+
passed: fileResult.valid,
|
|
1100
|
+
message: fileResult.valid ? `Valid ${fileResult.fileType} file (${formatSize3(fileResult.sizeBytes)})` : fileResult.errors.join("; ")
|
|
1101
|
+
});
|
|
1102
|
+
if (options.mappingFile) {
|
|
1103
|
+
try {
|
|
1104
|
+
const stats = await stat5(options.mappingFile);
|
|
1105
|
+
checks.push({
|
|
1106
|
+
name: "mapping",
|
|
1107
|
+
passed: stats.isFile(),
|
|
1108
|
+
message: stats.isFile() ? `Mapping file found (${formatSize3(stats.size)})` : "Mapping path is not a file"
|
|
1109
|
+
});
|
|
1110
|
+
} catch {
|
|
1111
|
+
checks.push({
|
|
1112
|
+
name: "mapping",
|
|
1113
|
+
passed: false,
|
|
1114
|
+
message: `Mapping file not found: ${options.mappingFile}`
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (options.track) {
|
|
1119
|
+
const isValid = STANDARD_TRACKS.has(options.track) || TRACK_PATTERN.test(options.track);
|
|
1120
|
+
checks.push({
|
|
1121
|
+
name: "track",
|
|
1122
|
+
passed: isValid,
|
|
1123
|
+
message: isValid ? `Track "${options.track}" is valid` : `Invalid track name "${options.track}". Use: internal, alpha, beta, production, or a custom track ID`
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
let resolvedNotes = options.notes;
|
|
1127
|
+
if (options.notesDir) {
|
|
1128
|
+
try {
|
|
1129
|
+
resolvedNotes = await readReleaseNotesFromDir(options.notesDir);
|
|
1130
|
+
checks.push({
|
|
1131
|
+
name: "notes-dir",
|
|
1132
|
+
passed: true,
|
|
1133
|
+
message: `Read release notes for ${resolvedNotes.length} language(s)`
|
|
1134
|
+
});
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
checks.push({
|
|
1137
|
+
name: "notes-dir",
|
|
1138
|
+
passed: false,
|
|
1139
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (resolvedNotes && resolvedNotes.length > 0) {
|
|
1144
|
+
const notesResult = validateReleaseNotes(resolvedNotes);
|
|
1145
|
+
checks.push({
|
|
1146
|
+
name: "notes",
|
|
1147
|
+
passed: notesResult.valid,
|
|
1148
|
+
message: notesResult.valid ? `Release notes valid (${resolvedNotes.length} language(s))` : notesResult.errors.join("; ")
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
valid: checks.every((c) => c.passed),
|
|
1153
|
+
checks
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function formatSize3(bytes) {
|
|
1157
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1158
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1159
|
+
return `${bytes} B`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/commands/publish.ts
|
|
1163
|
+
async function publish(client, packageName, filePath, options) {
|
|
1164
|
+
let releaseNotes;
|
|
1165
|
+
if (options.notesDir) {
|
|
1166
|
+
releaseNotes = await readReleaseNotesFromDir(options.notesDir);
|
|
1167
|
+
} else if (options.notes) {
|
|
1168
|
+
releaseNotes = [{ language: "en-US", text: options.notes }];
|
|
1169
|
+
}
|
|
1170
|
+
const validation = await validatePreSubmission({
|
|
1171
|
+
filePath,
|
|
1172
|
+
mappingFile: options.mappingFile,
|
|
1173
|
+
track: options.track || "internal",
|
|
1174
|
+
notes: releaseNotes
|
|
1175
|
+
});
|
|
1176
|
+
if (!validation.valid) {
|
|
1177
|
+
return { validation };
|
|
1178
|
+
}
|
|
1179
|
+
const upload = await uploadRelease(client, packageName, filePath, {
|
|
1180
|
+
track: options.track || "internal",
|
|
1181
|
+
userFraction: options.rolloutPercent ? options.rolloutPercent / 100 : void 0,
|
|
1182
|
+
releaseNotes,
|
|
1183
|
+
releaseName: options.releaseName,
|
|
1184
|
+
mappingFile: options.mappingFile
|
|
1185
|
+
});
|
|
1186
|
+
return { validation, upload };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/commands/reviews.ts
|
|
1190
|
+
import { paginateAll } from "@gpc-cli/api";
|
|
1191
|
+
async function listReviews(client, packageName, options) {
|
|
1192
|
+
const apiOptions = {};
|
|
1193
|
+
if (options?.translationLanguage) apiOptions.translationLanguage = options.translationLanguage;
|
|
1194
|
+
if (options?.maxResults) apiOptions.maxResults = options.maxResults;
|
|
1195
|
+
const response = await client.reviews.list(packageName, apiOptions);
|
|
1196
|
+
let reviews = response.reviews || [];
|
|
1197
|
+
if (options?.stars !== void 0) {
|
|
1198
|
+
reviews = reviews.filter((r) => {
|
|
1199
|
+
const userComment = r.comments?.[0]?.userComment;
|
|
1200
|
+
return userComment && userComment.starRating === options.stars;
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (options?.language) {
|
|
1204
|
+
reviews = reviews.filter((r) => {
|
|
1205
|
+
const userComment = r.comments?.[0]?.userComment;
|
|
1206
|
+
return userComment?.reviewerLanguage === options.language;
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
if (options?.since) {
|
|
1210
|
+
const sinceTime = new Date(options.since).getTime() / 1e3;
|
|
1211
|
+
reviews = reviews.filter((r) => {
|
|
1212
|
+
const userComment = r.comments?.[0]?.userComment;
|
|
1213
|
+
return userComment && Number(userComment.lastModified.seconds) >= sinceTime;
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
return reviews;
|
|
1217
|
+
}
|
|
1218
|
+
async function getReview(client, packageName, reviewId, translationLanguage) {
|
|
1219
|
+
return client.reviews.get(packageName, reviewId, translationLanguage);
|
|
1220
|
+
}
|
|
1221
|
+
var MAX_REPLY_LENGTH = 350;
|
|
1222
|
+
async function replyToReview(client, packageName, reviewId, replyText) {
|
|
1223
|
+
if (replyText.length > MAX_REPLY_LENGTH) {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
`Reply text exceeds ${MAX_REPLY_LENGTH} characters (${replyText.length}). Google Play limits replies to ${MAX_REPLY_LENGTH} characters.`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (replyText.length === 0) {
|
|
1229
|
+
throw new Error("Reply text cannot be empty.");
|
|
1230
|
+
}
|
|
1231
|
+
return client.reviews.reply(packageName, reviewId, replyText);
|
|
1232
|
+
}
|
|
1233
|
+
async function exportReviews(client, packageName, options) {
|
|
1234
|
+
const { items: allReviews } = await paginateAll(
|
|
1235
|
+
async (pageToken) => {
|
|
1236
|
+
const apiOptions = { token: pageToken };
|
|
1237
|
+
if (options?.translationLanguage) apiOptions.translationLanguage = options.translationLanguage;
|
|
1238
|
+
const response = await client.reviews.list(packageName, apiOptions);
|
|
1239
|
+
return { items: response.reviews || [], nextPageToken: response.tokenPagination?.nextPageToken };
|
|
1240
|
+
}
|
|
1241
|
+
);
|
|
1242
|
+
let filtered = allReviews;
|
|
1243
|
+
if (options?.stars !== void 0) {
|
|
1244
|
+
filtered = filtered.filter((r) => {
|
|
1245
|
+
const uc = r.comments?.[0]?.userComment;
|
|
1246
|
+
return uc && uc.starRating === options.stars;
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
if (options?.language) {
|
|
1250
|
+
filtered = filtered.filter((r) => {
|
|
1251
|
+
const uc = r.comments?.[0]?.userComment;
|
|
1252
|
+
return uc?.reviewerLanguage === options.language;
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
if (options?.since) {
|
|
1256
|
+
const sinceTime = new Date(options.since).getTime() / 1e3;
|
|
1257
|
+
filtered = filtered.filter((r) => {
|
|
1258
|
+
const uc = r.comments?.[0]?.userComment;
|
|
1259
|
+
return uc && Number(uc.lastModified.seconds) >= sinceTime;
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
if (options?.format === "csv") {
|
|
1263
|
+
return reviewsToCsv(filtered);
|
|
1264
|
+
}
|
|
1265
|
+
return JSON.stringify(filtered, null, 2);
|
|
1266
|
+
}
|
|
1267
|
+
function reviewsToCsv(reviews) {
|
|
1268
|
+
const header = "reviewId,authorName,starRating,text,language,date,device,appVersionName";
|
|
1269
|
+
const rows = reviews.map((r) => {
|
|
1270
|
+
const uc = r.comments?.[0]?.userComment;
|
|
1271
|
+
const fields = [
|
|
1272
|
+
r.reviewId,
|
|
1273
|
+
csvEscape(r.authorName),
|
|
1274
|
+
uc?.starRating ?? "",
|
|
1275
|
+
csvEscape(uc?.text ?? ""),
|
|
1276
|
+
uc?.reviewerLanguage ?? "",
|
|
1277
|
+
uc ? new Date(Number(uc.lastModified.seconds) * 1e3).toISOString() : "",
|
|
1278
|
+
csvEscape(uc?.device ?? ""),
|
|
1279
|
+
csvEscape(uc?.appVersionName ?? "")
|
|
1280
|
+
];
|
|
1281
|
+
return fields.join(",");
|
|
1282
|
+
});
|
|
1283
|
+
return [header, ...rows].join("\n");
|
|
1284
|
+
}
|
|
1285
|
+
function csvEscape(value) {
|
|
1286
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
1287
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
1288
|
+
}
|
|
1289
|
+
return value;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// src/commands/vitals.ts
|
|
1293
|
+
function buildQuery(options) {
|
|
1294
|
+
const query = {
|
|
1295
|
+
metrics: ["errorReportCount", "distinctUsers"]
|
|
1296
|
+
};
|
|
1297
|
+
if (options?.dimension) {
|
|
1298
|
+
query.dimensions = [options.dimension];
|
|
1299
|
+
}
|
|
1300
|
+
if (options?.days) {
|
|
1301
|
+
const end = /* @__PURE__ */ new Date();
|
|
1302
|
+
const start = /* @__PURE__ */ new Date();
|
|
1303
|
+
start.setDate(start.getDate() - options.days);
|
|
1304
|
+
query.timelineSpec = {
|
|
1305
|
+
aggregationPeriod: options.aggregation ?? "DAILY",
|
|
1306
|
+
startTime: {
|
|
1307
|
+
year: start.getFullYear(),
|
|
1308
|
+
month: start.getMonth() + 1,
|
|
1309
|
+
day: start.getDate()
|
|
1310
|
+
},
|
|
1311
|
+
endTime: {
|
|
1312
|
+
year: end.getFullYear(),
|
|
1313
|
+
month: end.getMonth() + 1,
|
|
1314
|
+
day: end.getDate()
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
return query;
|
|
1319
|
+
}
|
|
1320
|
+
async function queryMetric(reporting, packageName, metricSet, options) {
|
|
1321
|
+
const query = buildQuery(options);
|
|
1322
|
+
return reporting.queryMetricSet(packageName, metricSet, query);
|
|
1323
|
+
}
|
|
1324
|
+
async function getVitalsOverview(reporting, packageName) {
|
|
1325
|
+
const metricSets = [
|
|
1326
|
+
["vitals.crashrate", "crashRate"],
|
|
1327
|
+
["vitals.anrrate", "anrRate"],
|
|
1328
|
+
["vitals.slowstartrate", "slowStartRate"],
|
|
1329
|
+
["vitals.slowrenderingrate", "slowRenderingRate"],
|
|
1330
|
+
["vitals.excessivewakeuprate", "excessiveWakeupRate"],
|
|
1331
|
+
["vitals.stuckbackgroundwakelockrate", "stuckWakelockRate"]
|
|
1332
|
+
];
|
|
1333
|
+
const results = await Promise.allSettled(
|
|
1334
|
+
metricSets.map(
|
|
1335
|
+
([metric]) => reporting.queryMetricSet(packageName, metric, {
|
|
1336
|
+
metrics: ["errorReportCount", "distinctUsers"]
|
|
1337
|
+
})
|
|
1338
|
+
)
|
|
1339
|
+
);
|
|
1340
|
+
const overview = {};
|
|
1341
|
+
for (let i = 0; i < metricSets.length; i++) {
|
|
1342
|
+
const entry = metricSets[i];
|
|
1343
|
+
const key = entry[1];
|
|
1344
|
+
const result = results[i];
|
|
1345
|
+
if (result.status === "fulfilled") {
|
|
1346
|
+
overview[key] = result.value.rows || [];
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return overview;
|
|
1350
|
+
}
|
|
1351
|
+
async function getVitalsCrashes(reporting, packageName, options) {
|
|
1352
|
+
return queryMetric(reporting, packageName, "vitals.crashrate", options);
|
|
1353
|
+
}
|
|
1354
|
+
async function getVitalsAnr(reporting, packageName, options) {
|
|
1355
|
+
return queryMetric(reporting, packageName, "vitals.anrrate", options);
|
|
1356
|
+
}
|
|
1357
|
+
async function getVitalsStartup(reporting, packageName, options) {
|
|
1358
|
+
return queryMetric(reporting, packageName, "vitals.slowstartrate", options);
|
|
1359
|
+
}
|
|
1360
|
+
async function getVitalsRendering(reporting, packageName, options) {
|
|
1361
|
+
return queryMetric(reporting, packageName, "vitals.slowrenderingrate", options);
|
|
1362
|
+
}
|
|
1363
|
+
async function getVitalsBattery(reporting, packageName, options) {
|
|
1364
|
+
return queryMetric(reporting, packageName, "vitals.excessivewakeuprate", options);
|
|
1365
|
+
}
|
|
1366
|
+
async function getVitalsMemory(reporting, packageName, options) {
|
|
1367
|
+
return queryMetric(reporting, packageName, "vitals.stuckbackgroundwakelockrate", options);
|
|
1368
|
+
}
|
|
1369
|
+
async function getVitalsAnomalies(reporting, packageName) {
|
|
1370
|
+
return reporting.getAnomalies(packageName);
|
|
1371
|
+
}
|
|
1372
|
+
async function searchVitalsErrors(reporting, packageName, options) {
|
|
1373
|
+
return reporting.searchErrorIssues(
|
|
1374
|
+
packageName,
|
|
1375
|
+
options?.filter,
|
|
1376
|
+
options?.maxResults
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
async function compareVitalsTrend(reporting, packageName, metricSet, days = 7) {
|
|
1380
|
+
const now = /* @__PURE__ */ new Date();
|
|
1381
|
+
const currentEnd = new Date(now);
|
|
1382
|
+
const currentStart = new Date(now);
|
|
1383
|
+
currentStart.setDate(currentStart.getDate() - days);
|
|
1384
|
+
const previousEnd = new Date(currentStart);
|
|
1385
|
+
const previousStart = new Date(previousEnd);
|
|
1386
|
+
previousStart.setDate(previousStart.getDate() - days);
|
|
1387
|
+
const makeQuery = (start, end) => ({
|
|
1388
|
+
metrics: ["errorReportCount", "distinctUsers"],
|
|
1389
|
+
timelineSpec: {
|
|
1390
|
+
aggregationPeriod: "DAILY",
|
|
1391
|
+
startTime: { year: start.getFullYear(), month: start.getMonth() + 1, day: start.getDate() },
|
|
1392
|
+
endTime: { year: end.getFullYear(), month: end.getMonth() + 1, day: end.getDate() }
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
const [currentResult, previousResult] = await Promise.all([
|
|
1396
|
+
reporting.queryMetricSet(packageName, metricSet, makeQuery(currentStart, currentEnd)),
|
|
1397
|
+
reporting.queryMetricSet(packageName, metricSet, makeQuery(previousStart, previousEnd))
|
|
1398
|
+
]);
|
|
1399
|
+
const extractAvg = (rows) => {
|
|
1400
|
+
if (!rows || rows.length === 0) return void 0;
|
|
1401
|
+
const values = rows.map((r) => {
|
|
1402
|
+
const keys = Object.keys(r.metrics);
|
|
1403
|
+
const first = keys[0];
|
|
1404
|
+
return first ? Number(r.metrics[first]?.decimalValue?.value) : NaN;
|
|
1405
|
+
}).filter((v) => !isNaN(v));
|
|
1406
|
+
if (values.length === 0) return void 0;
|
|
1407
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
1408
|
+
};
|
|
1409
|
+
const current = extractAvg(currentResult.rows);
|
|
1410
|
+
const previous = extractAvg(previousResult.rows);
|
|
1411
|
+
let changePercent;
|
|
1412
|
+
let direction = "unknown";
|
|
1413
|
+
if (current !== void 0 && previous !== void 0 && previous !== 0) {
|
|
1414
|
+
changePercent = (current - previous) / previous * 100;
|
|
1415
|
+
if (Math.abs(changePercent) < 1) {
|
|
1416
|
+
direction = "unchanged";
|
|
1417
|
+
} else if (changePercent < 0) {
|
|
1418
|
+
direction = "improved";
|
|
1419
|
+
} else {
|
|
1420
|
+
direction = "degraded";
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
metric: metricSet,
|
|
1425
|
+
current,
|
|
1426
|
+
previous,
|
|
1427
|
+
changePercent: changePercent !== void 0 ? Math.round(changePercent * 10) / 10 : void 0,
|
|
1428
|
+
direction
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
function checkThreshold(value, threshold) {
|
|
1432
|
+
return {
|
|
1433
|
+
breached: value !== void 0 && value > threshold,
|
|
1434
|
+
value,
|
|
1435
|
+
threshold
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// src/commands/subscriptions.ts
|
|
1440
|
+
import { paginateAll as paginateAll2 } from "@gpc-cli/api";
|
|
1441
|
+
async function listSubscriptions(client, packageName, options) {
|
|
1442
|
+
if (options?.limit || options?.nextPage) {
|
|
1443
|
+
const result = await paginateAll2(
|
|
1444
|
+
async (pageToken) => {
|
|
1445
|
+
const resp = await client.subscriptions.list(packageName, { pageToken, pageSize: options?.pageSize });
|
|
1446
|
+
return { items: resp.subscriptions || [], nextPageToken: resp.nextPageToken };
|
|
1447
|
+
},
|
|
1448
|
+
{ limit: options.limit, startPageToken: options.nextPage }
|
|
1449
|
+
);
|
|
1450
|
+
return { subscriptions: result.items, nextPageToken: result.nextPageToken };
|
|
1451
|
+
}
|
|
1452
|
+
return client.subscriptions.list(packageName, { pageToken: options?.pageToken, pageSize: options?.pageSize });
|
|
1453
|
+
}
|
|
1454
|
+
async function getSubscription(client, packageName, productId) {
|
|
1455
|
+
return client.subscriptions.get(packageName, productId);
|
|
1456
|
+
}
|
|
1457
|
+
async function createSubscription(client, packageName, data) {
|
|
1458
|
+
return client.subscriptions.create(packageName, data);
|
|
1459
|
+
}
|
|
1460
|
+
async function updateSubscription(client, packageName, productId, data, updateMask) {
|
|
1461
|
+
return client.subscriptions.update(packageName, productId, data, updateMask);
|
|
1462
|
+
}
|
|
1463
|
+
async function deleteSubscription(client, packageName, productId) {
|
|
1464
|
+
return client.subscriptions.delete(packageName, productId);
|
|
1465
|
+
}
|
|
1466
|
+
async function activateBasePlan(client, packageName, productId, basePlanId) {
|
|
1467
|
+
return client.subscriptions.activateBasePlan(packageName, productId, basePlanId);
|
|
1468
|
+
}
|
|
1469
|
+
async function deactivateBasePlan(client, packageName, productId, basePlanId) {
|
|
1470
|
+
return client.subscriptions.deactivateBasePlan(packageName, productId, basePlanId);
|
|
1471
|
+
}
|
|
1472
|
+
async function deleteBasePlan(client, packageName, productId, basePlanId) {
|
|
1473
|
+
return client.subscriptions.deleteBasePlan(packageName, productId, basePlanId);
|
|
1474
|
+
}
|
|
1475
|
+
async function migratePrices(client, packageName, productId, basePlanId, data) {
|
|
1476
|
+
return client.subscriptions.migratePrices(packageName, productId, basePlanId, data);
|
|
1477
|
+
}
|
|
1478
|
+
async function listOffers(client, packageName, productId, basePlanId) {
|
|
1479
|
+
return client.subscriptions.listOffers(packageName, productId, basePlanId);
|
|
1480
|
+
}
|
|
1481
|
+
async function getOffer(client, packageName, productId, basePlanId, offerId) {
|
|
1482
|
+
return client.subscriptions.getOffer(packageName, productId, basePlanId, offerId);
|
|
1483
|
+
}
|
|
1484
|
+
async function createOffer(client, packageName, productId, basePlanId, data) {
|
|
1485
|
+
return client.subscriptions.createOffer(packageName, productId, basePlanId, data);
|
|
1486
|
+
}
|
|
1487
|
+
async function updateOffer(client, packageName, productId, basePlanId, offerId, data, updateMask) {
|
|
1488
|
+
return client.subscriptions.updateOffer(packageName, productId, basePlanId, offerId, data, updateMask);
|
|
1489
|
+
}
|
|
1490
|
+
async function deleteOffer(client, packageName, productId, basePlanId, offerId) {
|
|
1491
|
+
return client.subscriptions.deleteOffer(packageName, productId, basePlanId, offerId);
|
|
1492
|
+
}
|
|
1493
|
+
async function activateOffer(client, packageName, productId, basePlanId, offerId) {
|
|
1494
|
+
return client.subscriptions.activateOffer(packageName, productId, basePlanId, offerId);
|
|
1495
|
+
}
|
|
1496
|
+
async function deactivateOffer(client, packageName, productId, basePlanId, offerId) {
|
|
1497
|
+
return client.subscriptions.deactivateOffer(packageName, productId, basePlanId, offerId);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/commands/iap.ts
|
|
1501
|
+
import { readdir as readdir3, readFile as readFile4 } from "fs/promises";
|
|
1502
|
+
import { join as join3 } from "path";
|
|
1503
|
+
import { paginateAll as paginateAll3 } from "@gpc-cli/api";
|
|
1504
|
+
async function listInAppProducts(client, packageName, options) {
|
|
1505
|
+
if (options?.limit || options?.nextPage) {
|
|
1506
|
+
const result = await paginateAll3(
|
|
1507
|
+
async (pageToken) => {
|
|
1508
|
+
const resp = await client.inappproducts.list(packageName, { token: pageToken, maxResults: options?.maxResults });
|
|
1509
|
+
return { items: resp.inappproduct || [], nextPageToken: resp.tokenPagination?.nextPageToken };
|
|
1510
|
+
},
|
|
1511
|
+
{ limit: options.limit, startPageToken: options.nextPage }
|
|
1512
|
+
);
|
|
1513
|
+
return { inappproduct: result.items, nextPageToken: result.nextPageToken };
|
|
1514
|
+
}
|
|
1515
|
+
return client.inappproducts.list(packageName, { token: options?.token, maxResults: options?.maxResults });
|
|
1516
|
+
}
|
|
1517
|
+
async function getInAppProduct(client, packageName, sku) {
|
|
1518
|
+
return client.inappproducts.get(packageName, sku);
|
|
1519
|
+
}
|
|
1520
|
+
async function createInAppProduct(client, packageName, data) {
|
|
1521
|
+
return client.inappproducts.create(packageName, data);
|
|
1522
|
+
}
|
|
1523
|
+
async function updateInAppProduct(client, packageName, sku, data) {
|
|
1524
|
+
return client.inappproducts.update(packageName, sku, data);
|
|
1525
|
+
}
|
|
1526
|
+
async function deleteInAppProduct(client, packageName, sku) {
|
|
1527
|
+
return client.inappproducts.delete(packageName, sku);
|
|
1528
|
+
}
|
|
1529
|
+
async function syncInAppProducts(client, packageName, dir, options) {
|
|
1530
|
+
const files = await readdir3(dir);
|
|
1531
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
1532
|
+
if (jsonFiles.length === 0) {
|
|
1533
|
+
return { created: 0, updated: 0, unchanged: 0, skus: [] };
|
|
1534
|
+
}
|
|
1535
|
+
const localProducts = [];
|
|
1536
|
+
for (const file of jsonFiles) {
|
|
1537
|
+
const content = await readFile4(join3(dir, file), "utf-8");
|
|
1538
|
+
localProducts.push(JSON.parse(content));
|
|
1539
|
+
}
|
|
1540
|
+
const response = await client.inappproducts.list(packageName);
|
|
1541
|
+
const remoteSkus = new Set((response.inappproduct || []).map((p) => p.sku));
|
|
1542
|
+
let created = 0;
|
|
1543
|
+
let updated = 0;
|
|
1544
|
+
let unchanged = 0;
|
|
1545
|
+
const skus = [];
|
|
1546
|
+
for (const product of localProducts) {
|
|
1547
|
+
skus.push(product.sku);
|
|
1548
|
+
if (remoteSkus.has(product.sku)) {
|
|
1549
|
+
if (!options?.dryRun) {
|
|
1550
|
+
await client.inappproducts.update(packageName, product.sku, product);
|
|
1551
|
+
}
|
|
1552
|
+
updated++;
|
|
1553
|
+
} else {
|
|
1554
|
+
if (!options?.dryRun) {
|
|
1555
|
+
await client.inappproducts.create(packageName, product);
|
|
1556
|
+
}
|
|
1557
|
+
created++;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return { created, updated, unchanged, skus };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/commands/purchases.ts
|
|
1564
|
+
import { paginateAll as paginateAll4 } from "@gpc-cli/api";
|
|
1565
|
+
async function getProductPurchase(client, packageName, productId, token) {
|
|
1566
|
+
return client.purchases.getProduct(packageName, productId, token);
|
|
1567
|
+
}
|
|
1568
|
+
async function acknowledgeProductPurchase(client, packageName, productId, token, payload) {
|
|
1569
|
+
const body = payload ? { developerPayload: payload } : void 0;
|
|
1570
|
+
return client.purchases.acknowledgeProduct(packageName, productId, token, body);
|
|
1571
|
+
}
|
|
1572
|
+
async function consumeProductPurchase(client, packageName, productId, token) {
|
|
1573
|
+
return client.purchases.consumeProduct(packageName, productId, token);
|
|
1574
|
+
}
|
|
1575
|
+
async function getSubscriptionPurchase(client, packageName, token) {
|
|
1576
|
+
return client.purchases.getSubscriptionV2(packageName, token);
|
|
1577
|
+
}
|
|
1578
|
+
async function cancelSubscriptionPurchase(client, packageName, subscriptionId, token) {
|
|
1579
|
+
return client.purchases.cancelSubscription(packageName, subscriptionId, token);
|
|
1580
|
+
}
|
|
1581
|
+
async function deferSubscriptionPurchase(client, packageName, subscriptionId, token, desiredExpiry) {
|
|
1582
|
+
const sub = await client.purchases.getSubscriptionV1(packageName, subscriptionId, token);
|
|
1583
|
+
return client.purchases.deferSubscription(packageName, subscriptionId, token, {
|
|
1584
|
+
deferralInfo: {
|
|
1585
|
+
expectedExpiryTimeMillis: sub.expiryTimeMillis,
|
|
1586
|
+
desiredExpiryTimeMillis: String(new Date(desiredExpiry).getTime())
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
async function revokeSubscriptionPurchase(client, packageName, token) {
|
|
1591
|
+
return client.purchases.revokeSubscriptionV2(packageName, token);
|
|
1592
|
+
}
|
|
1593
|
+
async function listVoidedPurchases(client, packageName, options) {
|
|
1594
|
+
if (options?.limit || options?.nextPage) {
|
|
1595
|
+
const result = await paginateAll4(
|
|
1596
|
+
async (pageToken) => {
|
|
1597
|
+
const resp = await client.purchases.listVoided(packageName, {
|
|
1598
|
+
startTime: options?.startTime,
|
|
1599
|
+
endTime: options?.endTime,
|
|
1600
|
+
maxResults: options?.maxResults,
|
|
1601
|
+
token: pageToken
|
|
1602
|
+
});
|
|
1603
|
+
return { items: resp.voidedPurchases || [], nextPageToken: resp.tokenPagination?.nextPageToken };
|
|
1604
|
+
},
|
|
1605
|
+
{ limit: options.limit, startPageToken: options.nextPage }
|
|
1606
|
+
);
|
|
1607
|
+
return { voidedPurchases: result.items, nextPageToken: result.nextPageToken };
|
|
1608
|
+
}
|
|
1609
|
+
return client.purchases.listVoided(packageName, options);
|
|
1610
|
+
}
|
|
1611
|
+
async function refundOrder(client, packageName, orderId, options) {
|
|
1612
|
+
return client.orders.refund(packageName, orderId, options);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// src/commands/pricing.ts
|
|
1616
|
+
async function convertRegionPrices(client, packageName, currencyCode, amount) {
|
|
1617
|
+
const units = amount.split(".")[0] || "0";
|
|
1618
|
+
const fractional = amount.split(".")[1] || "0";
|
|
1619
|
+
const nanos = Number(fractional.padEnd(9, "0").slice(0, 9));
|
|
1620
|
+
return client.monetization.convertRegionPrices(packageName, {
|
|
1621
|
+
price: {
|
|
1622
|
+
currencyCode,
|
|
1623
|
+
units,
|
|
1624
|
+
nanos
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// src/commands/reports.ts
|
|
1630
|
+
var FINANCIAL_REPORT_TYPES = /* @__PURE__ */ new Set([
|
|
1631
|
+
"earnings",
|
|
1632
|
+
"sales",
|
|
1633
|
+
"estimated_sales",
|
|
1634
|
+
"play_balance"
|
|
1635
|
+
]);
|
|
1636
|
+
var STATS_REPORT_TYPES = /* @__PURE__ */ new Set([
|
|
1637
|
+
"installs",
|
|
1638
|
+
"crashes",
|
|
1639
|
+
"ratings",
|
|
1640
|
+
"reviews",
|
|
1641
|
+
"store_performance",
|
|
1642
|
+
"subscriptions"
|
|
1643
|
+
]);
|
|
1644
|
+
var VALID_DIMENSIONS = /* @__PURE__ */ new Set([
|
|
1645
|
+
"country",
|
|
1646
|
+
"language",
|
|
1647
|
+
"os_version",
|
|
1648
|
+
"device",
|
|
1649
|
+
"app_version",
|
|
1650
|
+
"carrier",
|
|
1651
|
+
"overview"
|
|
1652
|
+
]);
|
|
1653
|
+
function isFinancialReportType(type) {
|
|
1654
|
+
return FINANCIAL_REPORT_TYPES.has(type);
|
|
1655
|
+
}
|
|
1656
|
+
function isStatsReportType(type) {
|
|
1657
|
+
return STATS_REPORT_TYPES.has(type);
|
|
1658
|
+
}
|
|
1659
|
+
function isValidReportType(type) {
|
|
1660
|
+
return FINANCIAL_REPORT_TYPES.has(type) || STATS_REPORT_TYPES.has(type);
|
|
1661
|
+
}
|
|
1662
|
+
function isValidStatsDimension(dim) {
|
|
1663
|
+
return VALID_DIMENSIONS.has(dim);
|
|
1664
|
+
}
|
|
1665
|
+
function parseMonth(monthStr) {
|
|
1666
|
+
const match = /^(\d{4})-(\d{2})$/.exec(monthStr);
|
|
1667
|
+
if (!match) {
|
|
1668
|
+
throw new Error(
|
|
1669
|
+
`Invalid month format "${monthStr}". Expected YYYY-MM (e.g., 2026-03).`
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
const year = Number(match[1]);
|
|
1673
|
+
const month = Number(match[2]);
|
|
1674
|
+
if (month < 1 || month > 12) {
|
|
1675
|
+
throw new Error(`Invalid month "${month}". Must be between 01 and 12.`);
|
|
1676
|
+
}
|
|
1677
|
+
return { year, month };
|
|
1678
|
+
}
|
|
1679
|
+
async function listReports(client, packageName, reportType, year, month) {
|
|
1680
|
+
const response = await client.reports.list(packageName, reportType, year, month);
|
|
1681
|
+
return response.reports || [];
|
|
1682
|
+
}
|
|
1683
|
+
async function downloadReport(client, packageName, reportType, year, month) {
|
|
1684
|
+
const reports = await listReports(client, packageName, reportType, year, month);
|
|
1685
|
+
if (reports.length === 0) {
|
|
1686
|
+
throw new Error(
|
|
1687
|
+
`No ${reportType} reports found for ${year}-${String(month).padStart(2, "0")}.`
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
const bucket = reports[0];
|
|
1691
|
+
if (!bucket) {
|
|
1692
|
+
throw new Error(
|
|
1693
|
+
`No ${reportType} reports found for ${year}-${String(month).padStart(2, "0")}.`
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
const uri = bucket.uri;
|
|
1697
|
+
const response = await fetch(uri);
|
|
1698
|
+
if (!response.ok) {
|
|
1699
|
+
throw new Error(
|
|
1700
|
+
`Failed to download report from signed URI: HTTP ${response.status}`
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
return response.text();
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// src/commands/users.ts
|
|
1707
|
+
import { paginateAll as paginateAll5 } from "@gpc-cli/api";
|
|
1708
|
+
var PERMISSION_PROPAGATION_WARNING = "Note: Permission changes may take up to 48 hours to propagate.";
|
|
1709
|
+
async function listUsers(client, developerId, options) {
|
|
1710
|
+
if (options?.limit || options?.nextPage) {
|
|
1711
|
+
const result = await paginateAll5(
|
|
1712
|
+
async (pageToken) => {
|
|
1713
|
+
const resp = await client.list(developerId, { pageToken, pageSize: options?.pageSize });
|
|
1714
|
+
return { items: resp.users || [], nextPageToken: resp.nextPageToken };
|
|
1715
|
+
},
|
|
1716
|
+
{ limit: options.limit, startPageToken: options.nextPage }
|
|
1717
|
+
);
|
|
1718
|
+
return { users: result.items, nextPageToken: result.nextPageToken };
|
|
1719
|
+
}
|
|
1720
|
+
const response = await client.list(developerId, options);
|
|
1721
|
+
return { users: response.users || [], nextPageToken: response.nextPageToken };
|
|
1722
|
+
}
|
|
1723
|
+
async function getUser(client, developerId, userId) {
|
|
1724
|
+
return client.get(developerId, userId);
|
|
1725
|
+
}
|
|
1726
|
+
async function inviteUser(client, developerId, email, permissions, grants) {
|
|
1727
|
+
const user = { email };
|
|
1728
|
+
if (permissions) user.developerAccountPermission = permissions;
|
|
1729
|
+
if (grants) user.grants = grants;
|
|
1730
|
+
return client.create(developerId, user);
|
|
1731
|
+
}
|
|
1732
|
+
async function updateUser(client, developerId, userId, permissions, grants) {
|
|
1733
|
+
const updates = {};
|
|
1734
|
+
const masks = [];
|
|
1735
|
+
if (permissions) {
|
|
1736
|
+
updates.developerAccountPermission = permissions;
|
|
1737
|
+
masks.push("developerAccountPermission");
|
|
1738
|
+
}
|
|
1739
|
+
if (grants) {
|
|
1740
|
+
updates.grants = grants;
|
|
1741
|
+
masks.push("grants");
|
|
1742
|
+
}
|
|
1743
|
+
const updateMask = masks.length > 0 ? masks.join(",") : void 0;
|
|
1744
|
+
return client.update(developerId, userId, updates, updateMask);
|
|
1745
|
+
}
|
|
1746
|
+
async function removeUser(client, developerId, userId) {
|
|
1747
|
+
return client.delete(developerId, userId);
|
|
1748
|
+
}
|
|
1749
|
+
function parseGrantArg(grantStr) {
|
|
1750
|
+
const colonIdx = grantStr.indexOf(":");
|
|
1751
|
+
if (colonIdx === -1) {
|
|
1752
|
+
throw new Error(
|
|
1753
|
+
`Invalid grant format "${grantStr}". Expected <packageName>:<PERMISSION>[,<PERMISSION>...]`
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
const packageName = grantStr.slice(0, colonIdx);
|
|
1757
|
+
const perms = grantStr.slice(colonIdx + 1).split(",");
|
|
1758
|
+
return { packageName, appLevelPermissions: perms };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// src/commands/testers.ts
|
|
1762
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1763
|
+
async function listTesters(client, packageName, track) {
|
|
1764
|
+
const edit = await client.edits.insert(packageName);
|
|
1765
|
+
try {
|
|
1766
|
+
const testers = await client.testers.get(packageName, edit.id, track);
|
|
1767
|
+
return testers;
|
|
1768
|
+
} finally {
|
|
1769
|
+
await client.edits.delete(packageName, edit.id);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
async function addTesters(client, packageName, track, groupEmails) {
|
|
1773
|
+
const edit = await client.edits.insert(packageName);
|
|
1774
|
+
try {
|
|
1775
|
+
const current = await client.testers.get(packageName, edit.id, track);
|
|
1776
|
+
const existing = new Set(current.googleGroups || []);
|
|
1777
|
+
for (const email of groupEmails) {
|
|
1778
|
+
existing.add(email.trim());
|
|
1779
|
+
}
|
|
1780
|
+
const updated = await client.testers.update(packageName, edit.id, track, {
|
|
1781
|
+
googleGroups: [...existing]
|
|
1782
|
+
});
|
|
1783
|
+
await client.edits.validate(packageName, edit.id);
|
|
1784
|
+
await client.edits.commit(packageName, edit.id);
|
|
1785
|
+
return updated;
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
1788
|
+
});
|
|
1789
|
+
throw error;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
async function removeTesters(client, packageName, track, groupEmails) {
|
|
1793
|
+
const edit = await client.edits.insert(packageName);
|
|
1794
|
+
try {
|
|
1795
|
+
const current = await client.testers.get(packageName, edit.id, track);
|
|
1796
|
+
const toRemove = new Set(groupEmails.map((e) => e.trim()));
|
|
1797
|
+
const filtered = (current.googleGroups || []).filter(
|
|
1798
|
+
(g) => !toRemove.has(g)
|
|
1799
|
+
);
|
|
1800
|
+
const updated = await client.testers.update(packageName, edit.id, track, {
|
|
1801
|
+
googleGroups: filtered
|
|
1802
|
+
});
|
|
1803
|
+
await client.edits.validate(packageName, edit.id);
|
|
1804
|
+
await client.edits.commit(packageName, edit.id);
|
|
1805
|
+
return updated;
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
1808
|
+
});
|
|
1809
|
+
throw error;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
async function importTestersFromCsv(client, packageName, track, csvPath) {
|
|
1813
|
+
const content = await readFile5(csvPath, "utf-8");
|
|
1814
|
+
const emails = content.split(/[,\n\r]+/).map((e) => e.trim()).filter((e) => e.length > 0 && e.includes("@"));
|
|
1815
|
+
if (emails.length === 0) {
|
|
1816
|
+
throw new Error(`No valid email addresses found in ${csvPath}.`);
|
|
1817
|
+
}
|
|
1818
|
+
const testers = await addTesters(client, packageName, track, emails);
|
|
1819
|
+
return { added: emails.length, testers };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// src/utils/safe-path.ts
|
|
1823
|
+
import { resolve, normalize } from "path";
|
|
1824
|
+
function safePath(userPath) {
|
|
1825
|
+
return resolve(normalize(userPath));
|
|
1826
|
+
}
|
|
1827
|
+
function safePathWithin(userPath, baseDir) {
|
|
1828
|
+
const resolved = safePath(userPath);
|
|
1829
|
+
const base = safePath(baseDir);
|
|
1830
|
+
if (!resolved.startsWith(base + "/") && resolved !== base) {
|
|
1831
|
+
throw new Error(
|
|
1832
|
+
`Path "${userPath}" resolves outside the expected directory "${baseDir}"`
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
return resolved;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/commands/plugin-scaffold.ts
|
|
1839
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
1840
|
+
import { join as join4 } from "path";
|
|
1841
|
+
async function scaffoldPlugin(options) {
|
|
1842
|
+
const { name, dir, description = `GPC plugin: ${name}` } = options;
|
|
1843
|
+
const pluginName = name.startsWith("gpc-plugin-") ? name : `gpc-plugin-${name}`;
|
|
1844
|
+
const shortName = pluginName.replace(/^gpc-plugin-/, "");
|
|
1845
|
+
const srcDir = join4(dir, "src");
|
|
1846
|
+
const testDir = join4(dir, "tests");
|
|
1847
|
+
await mkdir2(srcDir, { recursive: true });
|
|
1848
|
+
await mkdir2(testDir, { recursive: true });
|
|
1849
|
+
const files = [];
|
|
1850
|
+
const pkg = {
|
|
1851
|
+
name: pluginName,
|
|
1852
|
+
version: "0.1.0",
|
|
1853
|
+
description,
|
|
1854
|
+
type: "module",
|
|
1855
|
+
main: "./dist/index.js",
|
|
1856
|
+
types: "./dist/index.d.ts",
|
|
1857
|
+
exports: {
|
|
1858
|
+
".": {
|
|
1859
|
+
import: "./dist/index.js",
|
|
1860
|
+
types: "./dist/index.d.ts"
|
|
1861
|
+
}
|
|
1862
|
+
},
|
|
1863
|
+
files: ["dist"],
|
|
1864
|
+
scripts: {
|
|
1865
|
+
build: "tsup src/index.ts --format esm --dts",
|
|
1866
|
+
dev: "tsup src/index.ts --format esm --dts --watch",
|
|
1867
|
+
test: "vitest run",
|
|
1868
|
+
"test:watch": "vitest"
|
|
1869
|
+
},
|
|
1870
|
+
keywords: ["gpc", "gpc-plugin", "google-play"],
|
|
1871
|
+
license: "MIT",
|
|
1872
|
+
peerDependencies: {
|
|
1873
|
+
"@gpc-cli/plugin-sdk": ">=0.8.0"
|
|
1874
|
+
},
|
|
1875
|
+
devDependencies: {
|
|
1876
|
+
"@gpc-cli/plugin-sdk": "^0.8.0",
|
|
1877
|
+
tsup: "^8.0.0",
|
|
1878
|
+
typescript: "^5.0.0",
|
|
1879
|
+
vitest: "^3.0.0"
|
|
1880
|
+
}
|
|
1881
|
+
};
|
|
1882
|
+
await writeFile2(join4(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
|
1883
|
+
files.push("package.json");
|
|
1884
|
+
const tsconfig = {
|
|
1885
|
+
compilerOptions: {
|
|
1886
|
+
target: "ES2022",
|
|
1887
|
+
module: "ESNext",
|
|
1888
|
+
moduleResolution: "bundler",
|
|
1889
|
+
declaration: true,
|
|
1890
|
+
strict: true,
|
|
1891
|
+
esModuleInterop: true,
|
|
1892
|
+
skipLibCheck: true,
|
|
1893
|
+
outDir: "./dist",
|
|
1894
|
+
rootDir: "./src"
|
|
1895
|
+
},
|
|
1896
|
+
include: ["src"]
|
|
1897
|
+
};
|
|
1898
|
+
await writeFile2(join4(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
|
|
1899
|
+
files.push("tsconfig.json");
|
|
1900
|
+
const srcContent = `import { definePlugin } from "@gpc-cli/plugin-sdk";
|
|
1901
|
+
import type { CommandEvent, CommandResult } from "@gpc-cli/plugin-sdk";
|
|
1902
|
+
|
|
1903
|
+
export const plugin = definePlugin({
|
|
1904
|
+
name: "${pluginName}",
|
|
1905
|
+
version: "0.1.0",
|
|
1906
|
+
|
|
1907
|
+
register(hooks) {
|
|
1908
|
+
hooks.beforeCommand(async (event: CommandEvent) => {
|
|
1909
|
+
// Called before every gpc command
|
|
1910
|
+
// Example: log command usage, validate prerequisites, etc.
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
hooks.afterCommand(async (event: CommandEvent, result: CommandResult) => {
|
|
1914
|
+
// Called after every successful gpc command
|
|
1915
|
+
// Example: send notifications, update dashboards, etc.
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
// Uncomment to add custom commands:
|
|
1919
|
+
// hooks.registerCommands((registry) => {
|
|
1920
|
+
// registry.add({
|
|
1921
|
+
// name: "${shortName}",
|
|
1922
|
+
// description: "${description}",
|
|
1923
|
+
// action: async (args, opts) => {
|
|
1924
|
+
// console.log("Hello from ${pluginName}!");
|
|
1925
|
+
// },
|
|
1926
|
+
// });
|
|
1927
|
+
// });
|
|
1928
|
+
},
|
|
1929
|
+
});
|
|
1930
|
+
`;
|
|
1931
|
+
await writeFile2(join4(srcDir, "index.ts"), srcContent);
|
|
1932
|
+
files.push("src/index.ts");
|
|
1933
|
+
const testContent = `import { describe, it, expect, vi } from "vitest";
|
|
1934
|
+
import { plugin } from "../src/index";
|
|
1935
|
+
|
|
1936
|
+
describe("${pluginName}", () => {
|
|
1937
|
+
it("has correct name and version", () => {
|
|
1938
|
+
expect(plugin.name).toBe("${pluginName}");
|
|
1939
|
+
expect(plugin.version).toBe("0.1.0");
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it("registers without errors", () => {
|
|
1943
|
+
const hooks = {
|
|
1944
|
+
beforeCommand: vi.fn(),
|
|
1945
|
+
afterCommand: vi.fn(),
|
|
1946
|
+
onError: vi.fn(),
|
|
1947
|
+
beforeRequest: vi.fn(),
|
|
1948
|
+
afterResponse: vi.fn(),
|
|
1949
|
+
registerCommands: vi.fn(),
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
expect(() => plugin.register(hooks)).not.toThrow();
|
|
1953
|
+
});
|
|
1954
|
+
});
|
|
1955
|
+
`;
|
|
1956
|
+
await writeFile2(join4(testDir, "plugin.test.ts"), testContent);
|
|
1957
|
+
files.push("tests/plugin.test.ts");
|
|
1958
|
+
return { dir, files };
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// src/audit.ts
|
|
1962
|
+
import { appendFile, chmod, mkdir as mkdir3 } from "fs/promises";
|
|
1963
|
+
import { join as join5 } from "path";
|
|
1964
|
+
var auditDir = null;
|
|
1965
|
+
function initAudit(configDir) {
|
|
1966
|
+
auditDir = configDir;
|
|
1967
|
+
}
|
|
1968
|
+
async function writeAuditLog(entry) {
|
|
1969
|
+
if (!auditDir) return;
|
|
1970
|
+
try {
|
|
1971
|
+
await mkdir3(auditDir, { recursive: true, mode: 448 });
|
|
1972
|
+
const logPath = join5(auditDir, "audit.log");
|
|
1973
|
+
const redactedEntry = redactAuditArgs(entry);
|
|
1974
|
+
const line = JSON.stringify(redactedEntry) + "\n";
|
|
1975
|
+
await appendFile(logPath, line, { encoding: "utf-8", mode: 384 });
|
|
1976
|
+
await chmod(logPath, 384).catch(() => {
|
|
1977
|
+
});
|
|
1978
|
+
} catch {
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
var SENSITIVE_ARG_KEYS = /* @__PURE__ */ new Set([
|
|
1982
|
+
"key",
|
|
1983
|
+
"keyFile",
|
|
1984
|
+
"key-file",
|
|
1985
|
+
"serviceAccount",
|
|
1986
|
+
"service-account",
|
|
1987
|
+
"token",
|
|
1988
|
+
"password",
|
|
1989
|
+
"secret",
|
|
1990
|
+
"credentials",
|
|
1991
|
+
"private_key",
|
|
1992
|
+
"client_secret"
|
|
1993
|
+
]);
|
|
1994
|
+
function redactAuditArgs(entry) {
|
|
1995
|
+
const redacted = {};
|
|
1996
|
+
for (const [k, v] of Object.entries(entry.args)) {
|
|
1997
|
+
redacted[k] = SENSITIVE_ARG_KEYS.has(k) ? "[REDACTED]" : v;
|
|
1998
|
+
}
|
|
1999
|
+
return { ...entry, args: redacted };
|
|
2000
|
+
}
|
|
2001
|
+
function createAuditEntry(command, args, app) {
|
|
2002
|
+
return {
|
|
2003
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2004
|
+
command,
|
|
2005
|
+
app,
|
|
2006
|
+
args
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
export {
|
|
2010
|
+
ApiError,
|
|
2011
|
+
ConfigError,
|
|
2012
|
+
GOOGLE_PLAY_LANGUAGES,
|
|
2013
|
+
GpcError,
|
|
2014
|
+
NetworkError,
|
|
2015
|
+
PERMISSION_PROPAGATION_WARNING,
|
|
2016
|
+
PluginManager,
|
|
2017
|
+
acknowledgeProductPurchase,
|
|
2018
|
+
activateBasePlan,
|
|
2019
|
+
activateOffer,
|
|
2020
|
+
addTesters,
|
|
2021
|
+
cancelSubscriptionPurchase,
|
|
2022
|
+
checkThreshold,
|
|
2023
|
+
compareVitalsTrend,
|
|
2024
|
+
consumeProductPurchase,
|
|
2025
|
+
convertRegionPrices,
|
|
2026
|
+
createAuditEntry,
|
|
2027
|
+
createInAppProduct,
|
|
2028
|
+
createOffer,
|
|
2029
|
+
createSubscription,
|
|
2030
|
+
deactivateBasePlan,
|
|
2031
|
+
deactivateOffer,
|
|
2032
|
+
deferSubscriptionPurchase,
|
|
2033
|
+
deleteBasePlan,
|
|
2034
|
+
deleteImage,
|
|
2035
|
+
deleteInAppProduct,
|
|
2036
|
+
deleteListing,
|
|
2037
|
+
deleteOffer,
|
|
2038
|
+
deleteSubscription,
|
|
2039
|
+
detectOutputFormat,
|
|
2040
|
+
diffListings,
|
|
2041
|
+
discoverPlugins,
|
|
2042
|
+
downloadReport,
|
|
2043
|
+
exportReviews,
|
|
2044
|
+
formatOutput,
|
|
2045
|
+
getAppInfo,
|
|
2046
|
+
getCountryAvailability,
|
|
2047
|
+
getInAppProduct,
|
|
2048
|
+
getListings,
|
|
2049
|
+
getOffer,
|
|
2050
|
+
getProductPurchase,
|
|
2051
|
+
getReleasesStatus,
|
|
2052
|
+
getReview,
|
|
2053
|
+
getSubscription,
|
|
2054
|
+
getSubscriptionPurchase,
|
|
2055
|
+
getUser,
|
|
2056
|
+
getVitalsAnomalies,
|
|
2057
|
+
getVitalsAnr,
|
|
2058
|
+
getVitalsBattery,
|
|
2059
|
+
getVitalsCrashes,
|
|
2060
|
+
getVitalsMemory,
|
|
2061
|
+
getVitalsOverview,
|
|
2062
|
+
getVitalsRendering,
|
|
2063
|
+
getVitalsStartup,
|
|
2064
|
+
importTestersFromCsv,
|
|
2065
|
+
initAudit,
|
|
2066
|
+
inviteUser,
|
|
2067
|
+
isFinancialReportType,
|
|
2068
|
+
isStatsReportType,
|
|
2069
|
+
isValidBcp47,
|
|
2070
|
+
isValidReportType,
|
|
2071
|
+
isValidStatsDimension,
|
|
2072
|
+
listImages,
|
|
2073
|
+
listInAppProducts,
|
|
2074
|
+
listOffers,
|
|
2075
|
+
listReports,
|
|
2076
|
+
listReviews,
|
|
2077
|
+
listSubscriptions,
|
|
2078
|
+
listTesters,
|
|
2079
|
+
listTracks,
|
|
2080
|
+
listUsers,
|
|
2081
|
+
listVoidedPurchases,
|
|
2082
|
+
migratePrices,
|
|
2083
|
+
parseGrantArg,
|
|
2084
|
+
parseMonth,
|
|
2085
|
+
promoteRelease,
|
|
2086
|
+
publish,
|
|
2087
|
+
pullListings,
|
|
2088
|
+
pushListings,
|
|
2089
|
+
readListingsFromDir,
|
|
2090
|
+
readReleaseNotesFromDir,
|
|
2091
|
+
redactSensitive,
|
|
2092
|
+
refundOrder,
|
|
2093
|
+
removeTesters,
|
|
2094
|
+
removeUser,
|
|
2095
|
+
replyToReview,
|
|
2096
|
+
revokeSubscriptionPurchase,
|
|
2097
|
+
safePath,
|
|
2098
|
+
safePathWithin,
|
|
2099
|
+
scaffoldPlugin,
|
|
2100
|
+
searchVitalsErrors,
|
|
2101
|
+
syncInAppProducts,
|
|
2102
|
+
updateAppDetails,
|
|
2103
|
+
updateInAppProduct,
|
|
2104
|
+
updateListing,
|
|
2105
|
+
updateOffer,
|
|
2106
|
+
updateRollout,
|
|
2107
|
+
updateSubscription,
|
|
2108
|
+
updateUser,
|
|
2109
|
+
uploadImage,
|
|
2110
|
+
uploadRelease,
|
|
2111
|
+
validateImage,
|
|
2112
|
+
validatePreSubmission,
|
|
2113
|
+
validateReleaseNotes,
|
|
2114
|
+
validateUploadFile,
|
|
2115
|
+
writeAuditLog,
|
|
2116
|
+
writeListingsToDir
|
|
2117
|
+
};
|
|
2118
|
+
//# sourceMappingURL=index.js.map
|