kappmaker 1.1.0 → 1.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/README.md +254 -30
- package/dist/cli.js +86 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adapty-setup.js +5 -4
- package/dist/commands/adapty-setup.js.map +1 -1
- package/dist/commands/config.js +11 -1
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/create-appstore-app.js +18 -1
- package/dist/commands/create-appstore-app.js.map +1 -1
- package/dist/commands/create-logo.js +16 -5
- package/dist/commands/create-logo.js.map +1 -1
- package/dist/commands/create-play-app.d.ts +2 -0
- package/dist/commands/create-play-app.js +399 -0
- package/dist/commands/create-play-app.js.map +1 -0
- package/dist/commands/create.js +133 -54
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/enhance.js +12 -4
- package/dist/commands/enhance.js.map +1 -1
- package/dist/commands/fastlane-configure.d.ts +1 -0
- package/dist/commands/fastlane-configure.js +9 -0
- package/dist/commands/fastlane-configure.js.map +1 -0
- package/dist/commands/generate-screenshots.js +22 -7
- package/dist/commands/generate-screenshots.js.map +1 -1
- package/dist/commands/gpc.d.ts +29 -0
- package/dist/commands/gpc.js +174 -0
- package/dist/commands/gpc.js.map +1 -0
- package/dist/commands/remove-bg.js +12 -4
- package/dist/commands/remove-bg.js.map +1 -1
- package/dist/commands/translate-screenshots.js +22 -8
- package/dist/commands/translate-screenshots.js.map +1 -1
- package/dist/services/asc-monetization.service.js +39 -29
- package/dist/services/asc-monetization.service.js.map +1 -1
- package/dist/services/asc.service.d.ts +1 -1
- package/dist/services/asc.service.js +94 -12
- package/dist/services/asc.service.js.map +1 -1
- package/dist/services/fastlane-setup.service.d.ts +1 -0
- package/dist/services/fastlane-setup.service.js +50 -0
- package/dist/services/fastlane-setup.service.js.map +1 -0
- package/dist/services/firebase.service.js +48 -18
- package/dist/services/firebase.service.js.map +1 -1
- package/dist/services/gpc-data-safety.service.d.ts +11 -0
- package/dist/services/gpc-data-safety.service.js +160 -0
- package/dist/services/gpc-data-safety.service.js.map +1 -0
- package/dist/services/gpc-monetization.service.d.ts +18 -0
- package/dist/services/gpc-monetization.service.js +244 -0
- package/dist/services/gpc-monetization.service.js.map +1 -0
- package/dist/services/gpc.service.d.ts +87 -0
- package/dist/services/gpc.service.js +338 -0
- package/dist/services/gpc.service.js.map +1 -0
- package/dist/services/refactor.service.js +44 -29
- package/dist/services/refactor.service.js.map +1 -1
- package/dist/templates/Fastfile.txt +209 -0
- package/dist/templates/data-safety-template.json +5483 -0
- package/dist/templates/googleplay-config.json +71 -0
- package/dist/types/googleplay.d.ts +113 -0
- package/dist/types/googleplay.js +2 -0
- package/dist/types/googleplay.js.map +1 -0
- package/dist/utils/config.d.ts +1 -0
- package/dist/utils/config.js +4 -0
- package/dist/utils/config.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js';
|
|
2
|
+
import { apiRequest } from './gpc.service.js';
|
|
3
|
+
/**
|
|
4
|
+
* Convert a human-readable price like "6.99" into the Google Play money
|
|
5
|
+
* representation: { currencyCode, units, nanos }. Nanos are billionths,
|
|
6
|
+
* so "6.99" becomes { units: "6", nanos: 990000000 }.
|
|
7
|
+
*/
|
|
8
|
+
export function priceToMoney(price, currencyCode) {
|
|
9
|
+
const [wholeRaw, fractionRaw = ''] = price.trim().split('.');
|
|
10
|
+
const whole = wholeRaw.replace(/^-/, '') || '0';
|
|
11
|
+
// Pad or truncate fractional part to 9 digits (nanos)
|
|
12
|
+
const fractionPadded = (fractionRaw + '000000000').slice(0, 9);
|
|
13
|
+
const nanos = Number(fractionPadded) || 0;
|
|
14
|
+
return {
|
|
15
|
+
currencyCode,
|
|
16
|
+
units: whole,
|
|
17
|
+
nanos: price.startsWith('-') ? -nanos : nanos,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function buildRegionalConfigs(prices) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const p of prices) {
|
|
23
|
+
out[p.region_code] = {
|
|
24
|
+
newSubscriberAvailability: true,
|
|
25
|
+
price: priceToMoney(p.price, p.currency_code),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export async function listSubscriptions(packageName) {
|
|
31
|
+
const result = await apiRequest({
|
|
32
|
+
method: 'GET',
|
|
33
|
+
path: `/applications/${encodeURIComponent(packageName)}/subscriptions`,
|
|
34
|
+
label: 'Looking up existing subscriptions',
|
|
35
|
+
allowFailure: true,
|
|
36
|
+
});
|
|
37
|
+
if (!result.ok || !result.data?.subscriptions)
|
|
38
|
+
return new Set();
|
|
39
|
+
return new Set(result.data.subscriptions.map((s) => s.productId));
|
|
40
|
+
}
|
|
41
|
+
export async function setupSubscriptions(packageName, subscriptions, playDefaultLanguage) {
|
|
42
|
+
if (subscriptions.length === 0) {
|
|
43
|
+
logger.info('No subscriptions configured, skipping.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const existing = await listSubscriptions(packageName);
|
|
47
|
+
for (const sub of subscriptions) {
|
|
48
|
+
if (existing.has(sub.product_id)) {
|
|
49
|
+
logger.info(`Subscription "${sub.product_id}" already exists, skipping create.`);
|
|
50
|
+
// Still (best-effort) try to activate any base plans that aren't active.
|
|
51
|
+
for (const basePlan of sub.base_plans) {
|
|
52
|
+
await activateBasePlan(packageName, sub.product_id, basePlan.base_plan_id);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
await createSubscriptionWithBasePlans(packageName, sub, playDefaultLanguage);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Google Play's new monetization API ties pricing regions to a "regionsVersion"
|
|
60
|
+
// — bump this constant if Google publishes a newer region catalog.
|
|
61
|
+
const REGIONS_VERSION = '2022/02';
|
|
62
|
+
async function createSubscriptionWithBasePlans(packageName, sub, playDefaultLanguage) {
|
|
63
|
+
// Play rejects subscriptions that don't have a listing in the app's current
|
|
64
|
+
// default language. If the config's listings don't cover it, clone the first
|
|
65
|
+
// entry with the Play default language tacked on.
|
|
66
|
+
const listings = [...sub.listings];
|
|
67
|
+
const hasDefault = listings.some((l) => l.locale === playDefaultLanguage);
|
|
68
|
+
if (!hasDefault && listings.length > 0) {
|
|
69
|
+
const first = listings[0];
|
|
70
|
+
listings.unshift({
|
|
71
|
+
locale: playDefaultLanguage,
|
|
72
|
+
title: first.title,
|
|
73
|
+
description: first.description,
|
|
74
|
+
benefits: first.benefits,
|
|
75
|
+
});
|
|
76
|
+
logger.info(`Cloned "${first.locale}" listing to "${playDefaultLanguage}" (Play default language).`);
|
|
77
|
+
}
|
|
78
|
+
// Create subscription + base plans in a single call. The new monetization API
|
|
79
|
+
// requires productId and regionsVersion.version as QUERY PARAMETERS (not body)
|
|
80
|
+
// — passing productId in the body triggers "Product ID must be specified".
|
|
81
|
+
const body = {
|
|
82
|
+
packageName,
|
|
83
|
+
productId: sub.product_id,
|
|
84
|
+
listings: listings.map((l) => ({
|
|
85
|
+
languageCode: l.locale,
|
|
86
|
+
title: l.title,
|
|
87
|
+
description: l.description ?? '',
|
|
88
|
+
benefits: l.benefits ?? [],
|
|
89
|
+
})),
|
|
90
|
+
basePlans: sub.base_plans.map((bp) => buildBasePlanBody(bp)),
|
|
91
|
+
};
|
|
92
|
+
const result = await apiRequest({
|
|
93
|
+
method: 'POST',
|
|
94
|
+
path: `/applications/${encodeURIComponent(packageName)}/subscriptions`,
|
|
95
|
+
query: {
|
|
96
|
+
productId: sub.product_id,
|
|
97
|
+
'regionsVersion.version': REGIONS_VERSION,
|
|
98
|
+
},
|
|
99
|
+
body,
|
|
100
|
+
label: `Creating subscription: ${sub.product_id}`,
|
|
101
|
+
allowFailure: true,
|
|
102
|
+
});
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
logger.warn(`Could not create subscription ${sub.product_id}. Skipping its base plans.`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// 2. Activate each base plan so it's available to buyers
|
|
108
|
+
for (const basePlan of sub.base_plans) {
|
|
109
|
+
await activateBasePlan(packageName, sub.product_id, basePlan.base_plan_id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function buildBasePlanBody(basePlan) {
|
|
113
|
+
const body = {
|
|
114
|
+
basePlanId: basePlan.base_plan_id,
|
|
115
|
+
state: 'DRAFT', // activated in a separate call
|
|
116
|
+
autoRenewingBasePlanType: {
|
|
117
|
+
billingPeriodDuration: basePlan.billing_period,
|
|
118
|
+
resubscribeState: 'RESUBSCRIBE_STATE_ACTIVE',
|
|
119
|
+
...(basePlan.grace_period ? { gracePeriodDuration: basePlan.grace_period } : {}),
|
|
120
|
+
},
|
|
121
|
+
regionalConfigs: Object.entries(buildRegionalConfigs(basePlan.regional_configs)).map(([region, cfg]) => ({
|
|
122
|
+
regionCode: region,
|
|
123
|
+
newSubscriberAvailability: cfg.newSubscriberAvailability,
|
|
124
|
+
price: cfg.price,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
return body;
|
|
128
|
+
}
|
|
129
|
+
export async function activateBasePlan(packageName, productId, basePlanId) {
|
|
130
|
+
await apiRequest({
|
|
131
|
+
method: 'POST',
|
|
132
|
+
path: `/applications/${encodeURIComponent(packageName)}/subscriptions/${encodeURIComponent(productId)}/basePlans/${encodeURIComponent(basePlanId)}:activate`,
|
|
133
|
+
body: {},
|
|
134
|
+
label: `Activating base plan ${productId}/${basePlanId}`,
|
|
135
|
+
allowFailure: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
export async function listInAppProducts(packageName) {
|
|
139
|
+
const existing = new Set();
|
|
140
|
+
let pageToken;
|
|
141
|
+
do {
|
|
142
|
+
const result = await apiRequest({
|
|
143
|
+
method: 'GET',
|
|
144
|
+
path: `/applications/${encodeURIComponent(packageName)}/oneTimeProducts`,
|
|
145
|
+
query: pageToken ? { pageToken } : undefined,
|
|
146
|
+
label: 'Looking up existing one-time products',
|
|
147
|
+
allowFailure: true,
|
|
148
|
+
});
|
|
149
|
+
if (!result.ok)
|
|
150
|
+
return existing;
|
|
151
|
+
for (const p of result.data?.oneTimeProducts ?? []) {
|
|
152
|
+
existing.add(p.productId);
|
|
153
|
+
}
|
|
154
|
+
pageToken = result.data?.nextPageToken;
|
|
155
|
+
} while (pageToken);
|
|
156
|
+
return existing;
|
|
157
|
+
}
|
|
158
|
+
export async function setupInAppProducts(packageName, products) {
|
|
159
|
+
if (products.length === 0) {
|
|
160
|
+
logger.info('No one-time in-app products configured, skipping.');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const existing = await listInAppProducts(packageName);
|
|
164
|
+
for (const product of products) {
|
|
165
|
+
if (existing.has(product.sku)) {
|
|
166
|
+
logger.info(`One-time product "${product.sku}" already exists, skipping.`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
await createInAppProduct(packageName, product);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function createInAppProduct(packageName, product) {
|
|
173
|
+
const listings = product.listings.map((loc) => ({
|
|
174
|
+
languageCode: loc.locale,
|
|
175
|
+
title: loc.title,
|
|
176
|
+
description: loc.description ?? '',
|
|
177
|
+
}));
|
|
178
|
+
// Build one purchase option with regional pricing from the config.
|
|
179
|
+
// We default to a single "default" purchase option covering the base price
|
|
180
|
+
// and any per-region overrides the user supplied.
|
|
181
|
+
const allPrices = [product.default_price, ...(product.prices ?? [])];
|
|
182
|
+
// Dedupe by region — if the user listed the same region twice, last wins.
|
|
183
|
+
const byRegion = new Map();
|
|
184
|
+
for (const p of allPrices)
|
|
185
|
+
byRegion.set(p.region_code, p);
|
|
186
|
+
const regionalPricingAndAvailabilityConfigs = Array.from(byRegion.values()).map((p) => ({
|
|
187
|
+
regionCode: p.region_code,
|
|
188
|
+
price: priceToMoney(p.price, p.currency_code),
|
|
189
|
+
availability: 'AVAILABLE',
|
|
190
|
+
}));
|
|
191
|
+
const body = {
|
|
192
|
+
packageName,
|
|
193
|
+
productId: product.sku,
|
|
194
|
+
listings,
|
|
195
|
+
purchaseOptions: [
|
|
196
|
+
{
|
|
197
|
+
purchaseOptionId: 'default',
|
|
198
|
+
buyOption: { legacyCompatible: true },
|
|
199
|
+
regionalPricingAndAvailabilityConfigs,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
// Note: patch path is lowercase `onetimeproducts` even though list/get use
|
|
204
|
+
// camelCase `oneTimeProducts`. Matches Google's v3 discovery document.
|
|
205
|
+
const result = await apiRequest({
|
|
206
|
+
method: 'PATCH',
|
|
207
|
+
path: `/applications/${encodeURIComponent(packageName)}/onetimeproducts/${encodeURIComponent(product.sku)}`,
|
|
208
|
+
query: {
|
|
209
|
+
allowMissing: 'true',
|
|
210
|
+
'regionsVersion.version': REGIONS_VERSION,
|
|
211
|
+
updateMask: 'listings,purchaseOptions',
|
|
212
|
+
},
|
|
213
|
+
body,
|
|
214
|
+
label: `Creating one-time product: ${product.sku}`,
|
|
215
|
+
allowFailure: true,
|
|
216
|
+
});
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
logger.warn(`Could not create one-time product ${product.sku}.`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// New purchase options land in DRAFT state — activate so buyers can see it.
|
|
222
|
+
await activatePurchaseOption(packageName, product.sku, 'default');
|
|
223
|
+
}
|
|
224
|
+
async function activatePurchaseOption(packageName, productId, purchaseOptionId) {
|
|
225
|
+
await apiRequest({
|
|
226
|
+
method: 'POST',
|
|
227
|
+
path: `/applications/${encodeURIComponent(packageName)}/oneTimeProducts/${encodeURIComponent(productId)}/purchaseOptions:batchUpdateStates`,
|
|
228
|
+
body: {
|
|
229
|
+
requests: [
|
|
230
|
+
{
|
|
231
|
+
activatePurchaseOptionRequest: {
|
|
232
|
+
packageName,
|
|
233
|
+
productId,
|
|
234
|
+
purchaseOptionId,
|
|
235
|
+
latencyTolerance: 'PRODUCT_UPDATE_LATENCY_TOLERANCE_LATENCY_SENSITIVE',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
label: `Activating purchase option ${productId}/${purchaseOptionId}`,
|
|
241
|
+
allowFailure: true,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=gpc-monetization.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gpc-monetization.service.js","sourceRoot":"","sources":["../../src/services/gpc-monetization.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { GooglePlayAppDetails, GooglePlayListing } from '../types/googleplay.js';
|
|
2
|
+
export declare const PLAY_CONSOLE_URL = "https://play.google.com/console/u/0/developers";
|
|
3
|
+
export declare function getAccessToken(): Promise<string>;
|
|
4
|
+
export interface ApiRequestOptions {
|
|
5
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
6
|
+
path: string;
|
|
7
|
+
query?: Record<string, string | undefined>;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
/** Swallow non-2xx responses and return null instead of exiting. */
|
|
10
|
+
allowFailure?: boolean;
|
|
11
|
+
label?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ApiResponse<T> {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
status: number;
|
|
16
|
+
data: T | null;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function apiRequest<T = unknown>(options: ApiRequestOptions): Promise<ApiResponse<T>>;
|
|
20
|
+
/**
|
|
21
|
+
* Probe whether an app exists on Play Console WITHOUT starting an edit.
|
|
22
|
+
* Uses GET /subscriptions (the new monetization API) which is read-only and
|
|
23
|
+
* works on apps that have been migrated to the new publishing API. The older
|
|
24
|
+
* `/inappproducts` endpoint returns 403 "Please migrate to the new publishing
|
|
25
|
+
* API" for such apps, so we avoid it here.
|
|
26
|
+
*
|
|
27
|
+
* Returns true on 200, false on 404, exits fatal on any other error.
|
|
28
|
+
*/
|
|
29
|
+
export declare function checkAppExists(packageName: string): Promise<boolean>;
|
|
30
|
+
export declare function validateServiceAccount(): Promise<void>;
|
|
31
|
+
export interface AppEdit {
|
|
32
|
+
id: string;
|
|
33
|
+
expiryTimeSeconds?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function insertEdit(packageName: string): Promise<string>;
|
|
36
|
+
export declare function commitEdit(packageName: string, editId: string): Promise<void>;
|
|
37
|
+
export declare function deleteEdit(packageName: string, editId: string): Promise<void>;
|
|
38
|
+
export interface PlayAppDetails {
|
|
39
|
+
defaultLanguage?: string;
|
|
40
|
+
contactWebsite?: string;
|
|
41
|
+
contactEmail?: string;
|
|
42
|
+
contactPhone?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read current app details (default language + contact info) inside an existing
|
|
46
|
+
* edit. Used to discover the app's actual default language before pushing
|
|
47
|
+
* subscriptions — Play rejects subscriptions that don't have a listing in that
|
|
48
|
+
* language, so the caller needs to ensure one exists.
|
|
49
|
+
*/
|
|
50
|
+
export declare function getEditDetails(packageName: string, editId: string): Promise<PlayAppDetails | null>;
|
|
51
|
+
/**
|
|
52
|
+
* Probe Play Console for the app's current default language via a short-lived
|
|
53
|
+
* edit (insert → get details → delete). Returns null if the probe fails so
|
|
54
|
+
* callers can fall back to whatever default they had in their config.
|
|
55
|
+
*/
|
|
56
|
+
export declare function fetchDefaultLanguage(packageName: string): Promise<string | null>;
|
|
57
|
+
export interface PlayAppState {
|
|
58
|
+
defaultLanguage: string | null;
|
|
59
|
+
hasUploadedBuild: boolean;
|
|
60
|
+
tracksWithReleases: string[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One-shot probe that starts an edit, reads the details and tracks, and
|
|
64
|
+
* discards the edit — returns everything the orchestrator needs to know about
|
|
65
|
+
* the app's current state in a single transaction. Useful for pre-flight
|
|
66
|
+
* checks before monetization writes.
|
|
67
|
+
*
|
|
68
|
+
* Graceful: returns null if the probe fails (no network, restricted service
|
|
69
|
+
* account, etc.).
|
|
70
|
+
*/
|
|
71
|
+
export declare function fetchAppState(packageName: string): Promise<PlayAppState | null>;
|
|
72
|
+
export declare function withEdit<T>(packageName: string, fn: (editId: string) => Promise<T>): Promise<T>;
|
|
73
|
+
export declare function updateAppDetails(packageName: string, editId: string, defaultLanguage: string, details: GooglePlayAppDetails): Promise<void>;
|
|
74
|
+
export declare function updateListing(packageName: string, editId: string, listing: GooglePlayListing): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Upload a Play Console Data Safety declaration. The API does NOT take
|
|
77
|
+
* structured JSON — it takes a JSON object with a single `safetyLabels` field
|
|
78
|
+
* whose value is the raw CSV content exported from Play Console:
|
|
79
|
+
*
|
|
80
|
+
* POST /applications/{pkg}/dataSafety
|
|
81
|
+
* body: { "safetyLabels": "<csv file contents as string>" }
|
|
82
|
+
*
|
|
83
|
+
* The CSV is app-specific (Google generates different question sets based on
|
|
84
|
+
* the app's category/permissions), so callers must provide the contents of a
|
|
85
|
+
* real CSV exported from this app's Play Console page.
|
|
86
|
+
*/
|
|
87
|
+
export declare function updateDataSafety(packageName: string, csvContents: string): Promise<void>;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createSign } from 'node:crypto';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { loadConfig } from '../utils/config.js';
|
|
7
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
8
|
+
const API_BASE = 'https://androidpublisher.googleapis.com/androidpublisher/v3';
|
|
9
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
10
|
+
const SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
|
11
|
+
export const PLAY_CONSOLE_URL = 'https://play.google.com/console/u/0/developers';
|
|
12
|
+
let cachedToken = null;
|
|
13
|
+
async function loadServiceAccount() {
|
|
14
|
+
const config = await loadConfig();
|
|
15
|
+
const resolved = path.resolve(config.googleServiceAccountPath.startsWith('~')
|
|
16
|
+
? config.googleServiceAccountPath.replace(/^~/, process.env.HOME ?? '')
|
|
17
|
+
: config.googleServiceAccountPath);
|
|
18
|
+
if (!(await fs.pathExists(resolved))) {
|
|
19
|
+
logger.fatal(`Google service account file not found: ${resolved}`);
|
|
20
|
+
logger.info('Set it with: kappmaker config set googleServiceAccountPath <path-to-json>');
|
|
21
|
+
logger.info('Create one at: https://console.cloud.google.com/iam-admin/serviceaccounts');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const sa = await fs.readJson(resolved);
|
|
25
|
+
if (!sa.client_email || !sa.private_key) {
|
|
26
|
+
logger.fatal(`Invalid service account JSON at ${resolved}: missing client_email or private_key`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
return sa;
|
|
30
|
+
}
|
|
31
|
+
function base64Url(input) {
|
|
32
|
+
return Buffer.from(input).toString('base64url');
|
|
33
|
+
}
|
|
34
|
+
function buildJwt(sa) {
|
|
35
|
+
const now = Math.floor(Date.now() / 1000);
|
|
36
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
37
|
+
const claim = {
|
|
38
|
+
iss: sa.client_email,
|
|
39
|
+
scope: SCOPE,
|
|
40
|
+
aud: sa.token_uri ?? TOKEN_URL,
|
|
41
|
+
exp: now + 3600,
|
|
42
|
+
iat: now,
|
|
43
|
+
};
|
|
44
|
+
const unsigned = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claim))}`;
|
|
45
|
+
const signer = createSign('RSA-SHA256');
|
|
46
|
+
signer.update(unsigned);
|
|
47
|
+
const signature = signer.sign(sa.private_key).toString('base64url');
|
|
48
|
+
return `${unsigned}.${signature}`;
|
|
49
|
+
}
|
|
50
|
+
export async function getAccessToken() {
|
|
51
|
+
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) {
|
|
52
|
+
return cachedToken.token;
|
|
53
|
+
}
|
|
54
|
+
const sa = await loadServiceAccount();
|
|
55
|
+
const jwt = buildJwt(sa);
|
|
56
|
+
const response = await fetch(TOKEN_URL, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
59
|
+
body: new URLSearchParams({
|
|
60
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
61
|
+
assertion: jwt,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const body = await response.text();
|
|
66
|
+
logger.fatal(`Failed to obtain Google access token: ${response.status} ${body}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
cachedToken = {
|
|
71
|
+
token: data.access_token,
|
|
72
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
73
|
+
};
|
|
74
|
+
return data.access_token;
|
|
75
|
+
}
|
|
76
|
+
export async function apiRequest(options) {
|
|
77
|
+
const token = await getAccessToken();
|
|
78
|
+
const label = options.label ?? `${options.method} ${options.path}`;
|
|
79
|
+
const spinner = ora({ text: label, indent: 4 }).start();
|
|
80
|
+
const url = new URL(`${API_BASE}${options.path}`);
|
|
81
|
+
if (options.query) {
|
|
82
|
+
for (const [k, v] of Object.entries(options.query)) {
|
|
83
|
+
if (v !== undefined)
|
|
84
|
+
url.searchParams.set(k, v);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(url.toString(), {
|
|
89
|
+
method: options.method,
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${token}`,
|
|
92
|
+
...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
93
|
+
},
|
|
94
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
95
|
+
});
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
let data = null;
|
|
98
|
+
if (text) {
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
data = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
spinner.fail(label);
|
|
108
|
+
const message = data?.error?.message ?? text ?? response.statusText;
|
|
109
|
+
if (options.allowFailure) {
|
|
110
|
+
logger.warn(`${label}: ${response.status} ${message}`);
|
|
111
|
+
return { ok: false, status: response.status, data, error: message };
|
|
112
|
+
}
|
|
113
|
+
logger.error(`${label}: ${response.status} ${message}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
spinner.succeed(label);
|
|
117
|
+
return { ok: true, status: response.status, data };
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
spinner.fail(label);
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
if (options.allowFailure) {
|
|
123
|
+
logger.warn(`${label}: ${message}`);
|
|
124
|
+
return { ok: false, status: 0, data: null, error: message };
|
|
125
|
+
}
|
|
126
|
+
logger.error(message);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ── App existence probe (side-effect-free) ──────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* Probe whether an app exists on Play Console WITHOUT starting an edit.
|
|
133
|
+
* Uses GET /subscriptions (the new monetization API) which is read-only and
|
|
134
|
+
* works on apps that have been migrated to the new publishing API. The older
|
|
135
|
+
* `/inappproducts` endpoint returns 403 "Please migrate to the new publishing
|
|
136
|
+
* API" for such apps, so we avoid it here.
|
|
137
|
+
*
|
|
138
|
+
* Returns true on 200, false on 404, exits fatal on any other error.
|
|
139
|
+
*/
|
|
140
|
+
export async function checkAppExists(packageName) {
|
|
141
|
+
const result = await apiRequest({
|
|
142
|
+
method: 'GET',
|
|
143
|
+
path: `/applications/${encodeURIComponent(packageName)}/subscriptions`,
|
|
144
|
+
label: `Checking ${packageName} on Play Console`,
|
|
145
|
+
allowFailure: true,
|
|
146
|
+
});
|
|
147
|
+
if (result.ok)
|
|
148
|
+
return true;
|
|
149
|
+
if (result.status === 404)
|
|
150
|
+
return false;
|
|
151
|
+
logger.fatal(`Play Console probe failed: ${result.status} ${result.error ?? ''}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
155
|
+
export async function validateServiceAccount() {
|
|
156
|
+
await loadServiceAccount(); // throws fatal if missing/invalid
|
|
157
|
+
try {
|
|
158
|
+
await getAccessToken();
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
logger.fatal(`Unable to authenticate with Google Play: ${message}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export async function insertEdit(packageName) {
|
|
167
|
+
const result = await apiRequest({
|
|
168
|
+
method: 'POST',
|
|
169
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits`,
|
|
170
|
+
body: {},
|
|
171
|
+
label: `Starting Play Console edit for ${packageName}`,
|
|
172
|
+
allowFailure: true,
|
|
173
|
+
});
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
if (result.status === 404) {
|
|
176
|
+
logger.fatal(`App "${packageName}" not found on Google Play Console.`);
|
|
177
|
+
logger.info('Apps must be created manually in Play Console before configuring them.');
|
|
178
|
+
logger.info(`Create it at: ${PLAY_CONSOLE_URL}`);
|
|
179
|
+
logger.info('Then rerun this command.');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
logger.fatal(`Failed to start Play Console edit: ${result.error ?? 'unknown error'}`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
if (!result.data?.id) {
|
|
186
|
+
logger.fatal('Play Console edit created but no id returned.');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
return result.data.id;
|
|
190
|
+
}
|
|
191
|
+
export async function commitEdit(packageName, editId) {
|
|
192
|
+
await apiRequest({
|
|
193
|
+
method: 'POST',
|
|
194
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits/${editId}:commit`,
|
|
195
|
+
label: 'Committing Play Console edit',
|
|
196
|
+
allowFailure: true,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
export async function deleteEdit(packageName, editId) {
|
|
200
|
+
await apiRequest({
|
|
201
|
+
method: 'DELETE',
|
|
202
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits/${editId}`,
|
|
203
|
+
label: 'Discarding Play Console edit',
|
|
204
|
+
allowFailure: true,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Read current app details (default language + contact info) inside an existing
|
|
209
|
+
* edit. Used to discover the app's actual default language before pushing
|
|
210
|
+
* subscriptions — Play rejects subscriptions that don't have a listing in that
|
|
211
|
+
* language, so the caller needs to ensure one exists.
|
|
212
|
+
*/
|
|
213
|
+
export async function getEditDetails(packageName, editId) {
|
|
214
|
+
const result = await apiRequest({
|
|
215
|
+
method: 'GET',
|
|
216
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits/${editId}/details`,
|
|
217
|
+
label: 'Fetching current app details',
|
|
218
|
+
allowFailure: true,
|
|
219
|
+
});
|
|
220
|
+
return result.ok ? (result.data ?? null) : null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Probe Play Console for the app's current default language via a short-lived
|
|
224
|
+
* edit (insert → get details → delete). Returns null if the probe fails so
|
|
225
|
+
* callers can fall back to whatever default they had in their config.
|
|
226
|
+
*/
|
|
227
|
+
export async function fetchDefaultLanguage(packageName) {
|
|
228
|
+
const state = await fetchAppState(packageName);
|
|
229
|
+
return state?.defaultLanguage ?? null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* One-shot probe that starts an edit, reads the details and tracks, and
|
|
233
|
+
* discards the edit — returns everything the orchestrator needs to know about
|
|
234
|
+
* the app's current state in a single transaction. Useful for pre-flight
|
|
235
|
+
* checks before monetization writes.
|
|
236
|
+
*
|
|
237
|
+
* Graceful: returns null if the probe fails (no network, restricted service
|
|
238
|
+
* account, etc.).
|
|
239
|
+
*/
|
|
240
|
+
export async function fetchAppState(packageName) {
|
|
241
|
+
let editId = null;
|
|
242
|
+
try {
|
|
243
|
+
editId = await insertEdit(packageName);
|
|
244
|
+
const details = await getEditDetails(packageName, editId);
|
|
245
|
+
const tracksResult = await apiRequest({
|
|
246
|
+
method: 'GET',
|
|
247
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits/${editId}/tracks`,
|
|
248
|
+
label: 'Fetching tracks and releases',
|
|
249
|
+
allowFailure: true,
|
|
250
|
+
});
|
|
251
|
+
const tracksWithReleases = [];
|
|
252
|
+
if (tracksResult.ok && tracksResult.data?.tracks) {
|
|
253
|
+
for (const track of tracksResult.data.tracks) {
|
|
254
|
+
const hasRelease = (track.releases ?? []).some((r) => (r.versionCodes?.length ?? 0) > 0);
|
|
255
|
+
if (hasRelease && track.track)
|
|
256
|
+
tracksWithReleases.push(track.track);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
defaultLanguage: details?.defaultLanguage ?? null,
|
|
261
|
+
hasUploadedBuild: tracksWithReleases.length > 0,
|
|
262
|
+
tracksWithReleases,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
if (editId) {
|
|
270
|
+
await deleteEdit(packageName, editId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export async function withEdit(packageName, fn) {
|
|
275
|
+
const editId = await insertEdit(packageName);
|
|
276
|
+
const result = await fn(editId);
|
|
277
|
+
await commitEdit(packageName, editId);
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
// ── App details ──────────────────────────────────────────────────────
|
|
281
|
+
export async function updateAppDetails(packageName, editId, defaultLanguage, details) {
|
|
282
|
+
const body = { defaultLanguage };
|
|
283
|
+
if (details.contact_website)
|
|
284
|
+
body.contactWebsite = details.contact_website;
|
|
285
|
+
if (details.contact_email)
|
|
286
|
+
body.contactEmail = details.contact_email;
|
|
287
|
+
if (details.contact_phone)
|
|
288
|
+
body.contactPhone = details.contact_phone;
|
|
289
|
+
await apiRequest({
|
|
290
|
+
method: 'PUT',
|
|
291
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits/${editId}/details`,
|
|
292
|
+
body,
|
|
293
|
+
label: 'Updating app details (default language + contact)',
|
|
294
|
+
allowFailure: true,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// ── Store listings ───────────────────────────────────────────────────
|
|
298
|
+
export async function updateListing(packageName, editId, listing) {
|
|
299
|
+
const body = { language: listing.locale };
|
|
300
|
+
if (listing.title)
|
|
301
|
+
body.title = listing.title;
|
|
302
|
+
if (listing.short_description)
|
|
303
|
+
body.shortDescription = listing.short_description;
|
|
304
|
+
if (listing.full_description)
|
|
305
|
+
body.fullDescription = listing.full_description;
|
|
306
|
+
if (listing.video)
|
|
307
|
+
body.video = listing.video;
|
|
308
|
+
await apiRequest({
|
|
309
|
+
method: 'PUT',
|
|
310
|
+
path: `/applications/${encodeURIComponent(packageName)}/edits/${editId}/listings/${encodeURIComponent(listing.locale)}`,
|
|
311
|
+
body,
|
|
312
|
+
label: `Updating listing (${listing.locale})`,
|
|
313
|
+
allowFailure: true,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// ── Data safety (standalone, not inside edit) ────────────────────────
|
|
317
|
+
/**
|
|
318
|
+
* Upload a Play Console Data Safety declaration. The API does NOT take
|
|
319
|
+
* structured JSON — it takes a JSON object with a single `safetyLabels` field
|
|
320
|
+
* whose value is the raw CSV content exported from Play Console:
|
|
321
|
+
*
|
|
322
|
+
* POST /applications/{pkg}/dataSafety
|
|
323
|
+
* body: { "safetyLabels": "<csv file contents as string>" }
|
|
324
|
+
*
|
|
325
|
+
* The CSV is app-specific (Google generates different question sets based on
|
|
326
|
+
* the app's category/permissions), so callers must provide the contents of a
|
|
327
|
+
* real CSV exported from this app's Play Console page.
|
|
328
|
+
*/
|
|
329
|
+
export async function updateDataSafety(packageName, csvContents) {
|
|
330
|
+
await apiRequest({
|
|
331
|
+
method: 'POST',
|
|
332
|
+
path: `/applications/${encodeURIComponent(packageName)}/dataSafety`,
|
|
333
|
+
body: { safetyLabels: csvContents },
|
|
334
|
+
label: 'Updating data safety declaration',
|
|
335
|
+
allowFailure: true,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
//# sourceMappingURL=gpc.service.js.map
|