@appeeky/spec 0.0.0-stage → 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/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +483 -2
- package/dist/ads.d.ts +39 -0
- package/dist/ads.js +67 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1387 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +270 -0
- package/dist/metrics.js +250 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +71 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +519 -0
- package/dist/schema.js +218 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +226 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +98 -0
- package/dist/transport.js +170 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility.d.ts +149 -0
- package/dist/visibility.js +239 -0
- package/package.json +76 -4
- package/skills/appeeky-spec/SKILL.md +146 -0
- package/skills/appeeky-spec-experiments/SKILL.md +83 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +79 -0
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business outcomes of a change, beyond search ranks: conversion, downloads,
|
|
3
|
+
* revenue, trials and paid installs. They come from the store analytics,
|
|
4
|
+
* Apple Search Ads and RevenueCat data a user has connected in Appeeky
|
|
5
|
+
* (`GET /v1/user/apps/:appId/metrics`), so they need APPEEKY_API_KEY.
|
|
6
|
+
*/
|
|
7
|
+
export const METRICS = {
|
|
8
|
+
conversion: { label: "conversion", kind: "rate", better: "up" },
|
|
9
|
+
page_views: { label: "page views/day", kind: "count", better: "up" },
|
|
10
|
+
downloads: { label: "downloads/day", kind: "count", better: "up" },
|
|
11
|
+
proceeds: { label: "proceeds/day", kind: "money", better: "up" },
|
|
12
|
+
trials: { label: "trials/day", kind: "count", better: "up" },
|
|
13
|
+
revenue: { label: "revenue/day", kind: "money", better: "up" },
|
|
14
|
+
rc_trials: { label: "RevenueCat trials/day", kind: "count", better: "up" },
|
|
15
|
+
trial_conversion: { label: "trial conversion", kind: "rate", better: "up" },
|
|
16
|
+
ad_installs: { label: "ad installs/day", kind: "count", better: "up" },
|
|
17
|
+
cpa: { label: "cost per ad install", kind: "money", better: "down" },
|
|
18
|
+
rating: { label: "average rating", kind: "count", better: "up" },
|
|
19
|
+
organic_installs: { label: "organic installs/day", kind: "count", better: "up" },
|
|
20
|
+
organic_revenue: { label: "organic revenue/day", kind: "money", better: "up" },
|
|
21
|
+
organic_paid: { label: "organic installs that paid", kind: "rate", better: "up" },
|
|
22
|
+
link_clicks: { label: "clicks on the experiment's link", kind: "count", better: "up" },
|
|
23
|
+
link_installs: { label: "installs from the experiment's link", kind: "count", better: "up" },
|
|
24
|
+
link_revenue: { label: "revenue from the experiment's link", kind: "money", better: "up" },
|
|
25
|
+
low_ratings: { label: "1–2 star reviews", kind: "rate", better: "down" },
|
|
26
|
+
meta_spend: { label: "Meta spend/day", kind: "money", better: "down" },
|
|
27
|
+
meta_installs: { label: "Meta installs/day", kind: "count", better: "up" },
|
|
28
|
+
meta_cpi: { label: "Meta cost per install", kind: "money", better: "down" },
|
|
29
|
+
tiktok_spend: { label: "TikTok spend/day", kind: "money", better: "down" },
|
|
30
|
+
tiktok_conversions: { label: "TikTok conversions/day", kind: "count", better: "up" },
|
|
31
|
+
tiktok_cpa: { label: "TikTok cost per conversion", kind: "money", better: "down" },
|
|
32
|
+
ai_visibility: { label: "AI answers that name the app", kind: "rate", better: "up" },
|
|
33
|
+
category_rank: { label: "category rank", kind: "count", better: "down" },
|
|
34
|
+
};
|
|
35
|
+
export function isMetricName(name) {
|
|
36
|
+
return name in METRICS;
|
|
37
|
+
}
|
|
38
|
+
/** Below this many product page views in a window, a conversion rate is noise. */
|
|
39
|
+
export const MIN_PAGE_VIEWS = 50;
|
|
40
|
+
const sum = (xs) => xs.reduce((n, x) => n + (x ?? 0), 0);
|
|
41
|
+
const known = (xs) => xs.filter((x) => x !== null && x !== undefined);
|
|
42
|
+
/**
|
|
43
|
+
* Sum one API response into a window. RevenueCat numbers are only used when
|
|
44
|
+
* the RevenueCat project holds just this app; project-wide numbers would
|
|
45
|
+
* credit a listing change with another app's revenue.
|
|
46
|
+
*/
|
|
47
|
+
export function summarizeMetrics(data, from, to) {
|
|
48
|
+
const values = {};
|
|
49
|
+
const store = data.store?.days ?? [];
|
|
50
|
+
const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
|
|
51
|
+
if (store.length > 0) {
|
|
52
|
+
const pageViews = sum(store.map((d) => d.pageViews));
|
|
53
|
+
const downloads = sum(store.map((d) => d.downloads));
|
|
54
|
+
if (known(store.map((d) => d.pageViews)).length)
|
|
55
|
+
values.page_views = pageViews / days;
|
|
56
|
+
if (known(store.map((d) => d.downloads)).length)
|
|
57
|
+
values.downloads = downloads / days;
|
|
58
|
+
if (pageViews >= MIN_PAGE_VIEWS)
|
|
59
|
+
values.conversion = downloads / pageViews;
|
|
60
|
+
if (known(store.map((d) => d.proceeds)).length)
|
|
61
|
+
values.proceeds = sum(store.map((d) => d.proceeds)) / days;
|
|
62
|
+
if (known(store.map((d) => d.trials)).length)
|
|
63
|
+
values.trials = sum(store.map((d) => d.trials)) / days;
|
|
64
|
+
}
|
|
65
|
+
const ads = data.ads?.days ?? [];
|
|
66
|
+
if (ads.length > 0) {
|
|
67
|
+
const installs = sum(ads.map((d) => d.installs));
|
|
68
|
+
values.ad_installs = installs / days;
|
|
69
|
+
if (installs > 0)
|
|
70
|
+
values.cpa = sum(ads.map((d) => d.spend)) / installs;
|
|
71
|
+
}
|
|
72
|
+
const rc = data.revenuecat?.scope === "app" ? data.revenuecat.days : [];
|
|
73
|
+
if (rc.length > 0) {
|
|
74
|
+
if (known(rc.map((d) => d.revenue)).length)
|
|
75
|
+
values.revenue = sum(rc.map((d) => d.revenue)) / days;
|
|
76
|
+
if (known(rc.map((d) => d.trials)).length)
|
|
77
|
+
values.rc_trials = sum(rc.map((d) => d.trials)) / days;
|
|
78
|
+
const tc = known(rc.map((d) => d.trialConversion));
|
|
79
|
+
if (tc.length)
|
|
80
|
+
values.trial_conversion = tc.reduce((a, b) => a + b, 0) / tc.length;
|
|
81
|
+
}
|
|
82
|
+
const currency = data.revenuecat?.currency ?? data.ads?.currency ?? undefined;
|
|
83
|
+
return { from, to, days, values, ...(currency ? { currency } : {}) };
|
|
84
|
+
}
|
|
85
|
+
/** Changes smaller than this are noise. */
|
|
86
|
+
export const FLAT_WITHIN = 0.05;
|
|
87
|
+
export function outcomeOf(metric, change, noise = 0) {
|
|
88
|
+
if (Math.abs(change) < Math.max(FLAT_WITHIN, noise))
|
|
89
|
+
return "flat";
|
|
90
|
+
const up = change > 0;
|
|
91
|
+
return METRICS[metric].better === "up" ? (up ? "better" : "worse") : up ? "worse" : "better";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* What a listing change is for: more people finding the app and paying. Organic numbers
|
|
95
|
+
* (from attribution) come first; without them, store conversion, then downloads, then
|
|
96
|
+
* the category rank (public, so it is there even without connected analytics).
|
|
97
|
+
*/
|
|
98
|
+
const LISTING_OUTCOMES = [["organic_installs", "organic_revenue"], ["conversion"], ["downloads"], ["category_rank"]];
|
|
99
|
+
/**
|
|
100
|
+
* A verdict from outcomes, for a release whose ranks did not move (or that has no
|
|
101
|
+
* targets): won when the listing outcomes got better, lost when worse.
|
|
102
|
+
*/
|
|
103
|
+
export function outcomeVerdict(changes) {
|
|
104
|
+
for (const group of LISTING_OUTCOMES) {
|
|
105
|
+
const found = changes.filter((c) => group.includes(c.metric));
|
|
106
|
+
if (found.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
const better = found.filter((c) => c.outcome === "better").length;
|
|
109
|
+
const worse = found.filter((c) => c.outcome === "worse").length;
|
|
110
|
+
const verdict = better && !worse ? "won" : worse && !better ? "lost" : better && worse ? "mixed" : "flat";
|
|
111
|
+
return { verdict, by: found.map((c) => c.metric) };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** Every metric present in both windows, with a usable before value. */
|
|
116
|
+
export function compareWindows(before, after, noise = {}) {
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const metric of Object.keys(METRICS)) {
|
|
119
|
+
const b = before.values[metric];
|
|
120
|
+
const a = after.values[metric];
|
|
121
|
+
if (b === undefined || a === undefined || b <= 0)
|
|
122
|
+
continue;
|
|
123
|
+
const change = a / b - 1;
|
|
124
|
+
const band = noise[metric];
|
|
125
|
+
out.push({ metric, before: b, after: a, change, outcome: outcomeOf(metric, change, band), ...(band !== undefined ? { noise: band } : {}) });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** Past windows used to learn how much each metric moves on its own. */
|
|
130
|
+
export const NOISE_WINDOWS = 4;
|
|
131
|
+
/**
|
|
132
|
+
* Twice the spread of window-to-window changes over consecutive past windows, per metric:
|
|
133
|
+
* roughly the range a metric moves in with nothing changed. Needs three or more changes.
|
|
134
|
+
*/
|
|
135
|
+
export function noiseBands(windows) {
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const metric of Object.keys(METRICS)) {
|
|
138
|
+
const changes = [];
|
|
139
|
+
for (let i = 1; i < windows.length; i++) {
|
|
140
|
+
const b = windows[i - 1].values[metric];
|
|
141
|
+
const a = windows[i].values[metric];
|
|
142
|
+
if (b !== undefined && a !== undefined && b > 0)
|
|
143
|
+
changes.push(a / b - 1);
|
|
144
|
+
}
|
|
145
|
+
if (changes.length < 3)
|
|
146
|
+
continue;
|
|
147
|
+
const mean = changes.reduce((s, x) => s + x, 0) / changes.length;
|
|
148
|
+
const sd = Math.sqrt(changes.reduce((s, x) => s + (x - mean) ** 2, 0) / (changes.length - 1));
|
|
149
|
+
out[metric] = Math.round(2 * sd * 1000) / 1000;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** Meta counts an app install under one of these action types, depending on how the campaign is set up. */
|
|
154
|
+
const META_INSTALL_ACTIONS = ["mobile_app_install", "app_install", "omni_app_install"];
|
|
155
|
+
const num = (v) => {
|
|
156
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
157
|
+
return Number.isFinite(n) ? n : 0;
|
|
158
|
+
};
|
|
159
|
+
/** Spend and installs of the given Meta campaigns' insights rows, per day. */
|
|
160
|
+
export function summarizeMeta(rows, days) {
|
|
161
|
+
if (rows.length === 0)
|
|
162
|
+
return {};
|
|
163
|
+
const spend = rows.reduce((n, r) => n + num(r.spend), 0);
|
|
164
|
+
const installs = rows.reduce((n, r) => {
|
|
165
|
+
const actions = r.actions ?? [];
|
|
166
|
+
// Take the first install type present so one install is not counted twice.
|
|
167
|
+
const type = META_INSTALL_ACTIONS.find((t) => actions.some((a) => a.action_type === t));
|
|
168
|
+
return n + (type ? num(actions.find((a) => a.action_type === type)?.value) : 0);
|
|
169
|
+
}, 0);
|
|
170
|
+
const values = { meta_spend: spend / days, meta_installs: installs / days };
|
|
171
|
+
if (installs > 0)
|
|
172
|
+
values.meta_cpi = spend / installs;
|
|
173
|
+
return values;
|
|
174
|
+
}
|
|
175
|
+
/** Spend and conversions of the given TikTok campaigns, per day. Rows for other campaigns are left out. */
|
|
176
|
+
export function summarizeTikTok(rows, campaigns, days) {
|
|
177
|
+
const mine = rows.filter((r) => r.dimensions?.campaign_id && campaigns.includes(String(r.dimensions.campaign_id)));
|
|
178
|
+
if (mine.length === 0)
|
|
179
|
+
return {};
|
|
180
|
+
const spend = mine.reduce((n, r) => n + num(r.metrics?.spend), 0);
|
|
181
|
+
const conversions = mine.reduce((n, r) => n + num(r.metrics?.conversion), 0);
|
|
182
|
+
const values = { tiktok_spend: spend / days, tiktok_conversions: conversions / days };
|
|
183
|
+
if (conversions > 0)
|
|
184
|
+
values.tiktok_cpa = spend / conversions;
|
|
185
|
+
return values;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Several sources as one: each window merges the values every source has.
|
|
189
|
+
* A source that fails or has no data is skipped, so one broken connection
|
|
190
|
+
* does not hide the others.
|
|
191
|
+
*/
|
|
192
|
+
export function combineSources(sources) {
|
|
193
|
+
return {
|
|
194
|
+
label: sources.map((s) => s.label).join(", "),
|
|
195
|
+
async window(store, appId, from, to) {
|
|
196
|
+
const windows = await Promise.all(sources.map((s) => s.window(store, appId, from, to).catch(() => null)));
|
|
197
|
+
const found = windows.filter((w) => w !== null);
|
|
198
|
+
if (found.length === 0)
|
|
199
|
+
return null;
|
|
200
|
+
const currency = found.find((w) => w.currency)?.currency;
|
|
201
|
+
return {
|
|
202
|
+
from,
|
|
203
|
+
to,
|
|
204
|
+
days: found[0].days,
|
|
205
|
+
values: Object.assign({}, ...found.map((w) => w.values)),
|
|
206
|
+
...(currency ? { currency } : {}),
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
212
|
+
/** Compare the `days` before a moment (a push, an experiment start) with the `days` after it. */
|
|
213
|
+
export async function measureAround(source, store, appId, at, days) {
|
|
214
|
+
const t = Date.parse(at);
|
|
215
|
+
const span = Math.max(1, days) * 86_400_000;
|
|
216
|
+
// The day of the change itself is mixed, so it is in neither window.
|
|
217
|
+
const before = await source.window(store, appId, iso(t - span), iso(t));
|
|
218
|
+
const after = await source.window(store, appId, iso(t + 86_400_000), iso(t + 86_400_000 + span));
|
|
219
|
+
if (!before || !after)
|
|
220
|
+
return null;
|
|
221
|
+
// The windows before `before`, oldest first, to learn each metric's usual move.
|
|
222
|
+
const past = [];
|
|
223
|
+
for (let k = NOISE_WINDOWS; k >= 1; k--) {
|
|
224
|
+
const w = await source.window(store, appId, iso(t - (k + 1) * span), iso(t - k * span)).catch(() => null);
|
|
225
|
+
if (w)
|
|
226
|
+
past.push(w);
|
|
227
|
+
}
|
|
228
|
+
const changes = compareWindows(before, after, noiseBands([...past, before]));
|
|
229
|
+
return changes.length > 0 ? { source: source.label, before, after, changes } : null;
|
|
230
|
+
}
|
|
231
|
+
/** One metric's average over the `days` before a moment, for an experiment baseline. */
|
|
232
|
+
export async function metricBefore(source, store, appId, metric, at, days) {
|
|
233
|
+
const t = Date.parse(at);
|
|
234
|
+
const window = await source.window(store, appId, iso(t - Math.max(1, days) * 86_400_000), iso(t));
|
|
235
|
+
return window?.values[metric];
|
|
236
|
+
}
|
|
237
|
+
/** One metric's average over the `days` from a moment on, for an experiment result. */
|
|
238
|
+
export async function metricAfter(source, store, appId, metric, at, days) {
|
|
239
|
+
const t = Date.parse(at);
|
|
240
|
+
const window = await source.window(store, appId, iso(t), iso(t + Math.max(1, days) * 86_400_000));
|
|
241
|
+
return window?.values[metric];
|
|
242
|
+
}
|
|
243
|
+
/** "conversion 3.1% → 3.6% (+16%)", "revenue/day $41.20 → $47.90 (+16%)" */
|
|
244
|
+
export function formatChange(c, currency) {
|
|
245
|
+
const { kind, label } = METRICS[c.metric];
|
|
246
|
+
const fmt = (v) => kind === "rate" ? `${(v * 100).toFixed(1)}%` : kind === "money" ? `${currency && currency !== "USD" ? `${currency} ` : "$"}${v.toFixed(2)}` : v >= 10 ? v.toFixed(0) : v.toFixed(1);
|
|
247
|
+
const pct = `${c.change >= 0 ? "+" : ""}${(c.change * 100).toFixed(0)}%`;
|
|
248
|
+
return `${label} ${fmt(c.before)} → ${fmt(c.after)} (${pct})`;
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=metrics.js.map
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { StorefrontCoverage } from "./coverage.js";
|
|
2
|
+
import type { Issue } from "./lint.js";
|
|
3
|
+
import type { FieldChange } from "./stores/types.js";
|
|
4
|
+
export declare const c: {
|
|
5
|
+
bold: (s: string) => string;
|
|
6
|
+
dim: (s: string) => string;
|
|
7
|
+
red: (s: string) => string;
|
|
8
|
+
green: (s: string) => string;
|
|
9
|
+
yellow: (s: string) => string;
|
|
10
|
+
cyan: (s: string) => string;
|
|
11
|
+
};
|
|
12
|
+
export declare function printJson(value: unknown): void;
|
|
13
|
+
declare const STORE_LABEL: {
|
|
14
|
+
readonly "app-store": "App Store";
|
|
15
|
+
readonly "google-play": "Google Play";
|
|
16
|
+
};
|
|
17
|
+
export declare const storeLabel: (s: keyof typeof STORE_LABEL) => "App Store" | "Google Play";
|
|
18
|
+
export declare function formatIssues(issues: Issue[]): string;
|
|
19
|
+
export declare function formatChanges(changes: FieldChange[]): string;
|
|
20
|
+
export declare function formatCoverage(reports: StorefrontCoverage[]): string;
|
|
21
|
+
export {};
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const wrap = (code) => (s) => (useColor ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
3
|
+
export const c = {
|
|
4
|
+
bold: wrap(1),
|
|
5
|
+
dim: wrap(2),
|
|
6
|
+
red: wrap(31),
|
|
7
|
+
green: wrap(32),
|
|
8
|
+
yellow: wrap(33),
|
|
9
|
+
cyan: wrap(36),
|
|
10
|
+
};
|
|
11
|
+
export function printJson(value) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
13
|
+
}
|
|
14
|
+
const STORE_LABEL = { "app-store": "App Store", "google-play": "Google Play" };
|
|
15
|
+
export const storeLabel = (s) => STORE_LABEL[s];
|
|
16
|
+
export function formatIssues(issues) {
|
|
17
|
+
if (issues.length === 0)
|
|
18
|
+
return c.green("No issues.");
|
|
19
|
+
const icon = { error: c.red("error"), warning: c.yellow("warn "), info: c.cyan("info ") };
|
|
20
|
+
return issues
|
|
21
|
+
.map((i) => `${icon[i.severity]} ${c.dim(`${storeLabel(i.store)} ${i.locale}${i.field ? ` ${i.field}` : ""}`)} ${i.message} ${c.dim(`(${i.rule})`)}`)
|
|
22
|
+
.join("\n");
|
|
23
|
+
}
|
|
24
|
+
function preview(value, width = 70) {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return c.dim("(empty)");
|
|
27
|
+
const flat = value.replace(/\n+/g, " ⏎ ");
|
|
28
|
+
return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
|
|
29
|
+
}
|
|
30
|
+
export function formatChanges(changes) {
|
|
31
|
+
if (changes.length === 0)
|
|
32
|
+
return c.green("In sync with the store.");
|
|
33
|
+
const lines = [];
|
|
34
|
+
let last = "";
|
|
35
|
+
for (const ch of changes) {
|
|
36
|
+
const head = `${storeLabel(ch.store)} ${ch.locale}${ch.newLocale ? c.yellow(" (new locale)") : ""}`;
|
|
37
|
+
if (head !== last) {
|
|
38
|
+
lines.push(c.bold(head));
|
|
39
|
+
last = head;
|
|
40
|
+
}
|
|
41
|
+
lines.push(` ${ch.field}`);
|
|
42
|
+
lines.push(` ${c.red("-")} ${preview(ch.before)}`);
|
|
43
|
+
lines.push(` ${c.green("+")} ${preview(ch.after)}`);
|
|
44
|
+
if (ch.blocked)
|
|
45
|
+
lines.push(` ${c.yellow("blocked:")} ${ch.blocked}`);
|
|
46
|
+
}
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
const LEVEL_LABEL = {
|
|
50
|
+
title: "in title",
|
|
51
|
+
subtitle: "name+subtitle",
|
|
52
|
+
short: "short desc",
|
|
53
|
+
full: "indexed",
|
|
54
|
+
split: "split locales",
|
|
55
|
+
partial: "partial",
|
|
56
|
+
none: "missing",
|
|
57
|
+
};
|
|
58
|
+
export function formatCoverage(reports) {
|
|
59
|
+
if (reports.length === 0)
|
|
60
|
+
return c.dim("No targets yet. Add keywords under `targets:` in appeeky.spec.yaml.");
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const r of reports) {
|
|
63
|
+
const weighted = r.weightedPercent !== undefined ? `, ${r.weightedPercent}% by popularity` : "";
|
|
64
|
+
out.push(c.bold(`${storeLabel(r.store)} ${r.country.toUpperCase()}`) +
|
|
65
|
+
c.dim(` (${r.locales.join(", ") || "no listing"})`) +
|
|
66
|
+
` ${r.covered}/${r.total} covered (${r.percent}%${weighted})`);
|
|
67
|
+
const rows = [...r.keywords].sort((a, b) => (b.popularity ?? -1) - (a.popularity ?? -1));
|
|
68
|
+
for (const k of rows) {
|
|
69
|
+
const ok = ["title", "subtitle", "short", "full"].includes(k.level);
|
|
70
|
+
const mark = ok ? c.green("✓") : k.level === "none" ? c.red("✗") : c.yellow("~");
|
|
71
|
+
const pop = k.popularity !== undefined ? c.dim(` pop ${String(k.popularity).padStart(3)} diff ${String(k.difficulty ?? "-").padStart(3)}`) : "";
|
|
72
|
+
const missing = k.missing.length > 0 ? c.dim(` missing: ${k.missing.join(", ")}`) : "";
|
|
73
|
+
out.push(` ${mark} ${k.keyword.padEnd(28)} ${LEVEL_LABEL[k.level].padEnd(14)}${pop}${missing}`);
|
|
74
|
+
}
|
|
75
|
+
out.push("");
|
|
76
|
+
}
|
|
77
|
+
return out.join("\n").trimEnd();
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=output.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Project, StoreId } from "./schema.js";
|
|
2
|
+
import type { FieldChange, RemoteState, StoreAdapter } from "./stores/types.js";
|
|
3
|
+
import { type OpsTransport, type TransportOptions } from "./transport.js";
|
|
4
|
+
export interface StoreHandle {
|
|
5
|
+
store: StoreId;
|
|
6
|
+
adapter: StoreAdapter;
|
|
7
|
+
transport: OpsTransport;
|
|
8
|
+
}
|
|
9
|
+
/** Stores configured in the spec, optionally narrowed to one. */
|
|
10
|
+
export declare function configuredStores(project: Project, only?: StoreId): StoreId[];
|
|
11
|
+
export declare function storeHandle(project: Project, store: StoreId, overrides?: Partial<TransportOptions>, transportOverride?: OpsTransport): StoreHandle;
|
|
12
|
+
export interface StorePlan {
|
|
13
|
+
store: StoreId;
|
|
14
|
+
via: string;
|
|
15
|
+
state: RemoteState;
|
|
16
|
+
changes: FieldChange[];
|
|
17
|
+
}
|
|
18
|
+
/** Pull the store, diff it with the spec and mark what cannot be pushed yet. */
|
|
19
|
+
export declare function planStore(project: Project, handle: StoreHandle): Promise<StorePlan>;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { diffListings } from "./diff.js";
|
|
2
|
+
import { AppStoreAdapter } from "./stores/app-store.js";
|
|
3
|
+
import { GooglePlayAdapter } from "./stores/google-play.js";
|
|
4
|
+
import { createTransport } from "./transport.js";
|
|
5
|
+
/** Stores configured in the spec, optionally narrowed to one. */
|
|
6
|
+
export function configuredStores(project, only) {
|
|
7
|
+
const stores = [];
|
|
8
|
+
if (project.spec.stores.appStore)
|
|
9
|
+
stores.push("app-store");
|
|
10
|
+
if (project.spec.stores.googlePlay)
|
|
11
|
+
stores.push("google-play");
|
|
12
|
+
return only ? stores.filter((s) => s === only) : stores;
|
|
13
|
+
}
|
|
14
|
+
export function storeHandle(project, store, overrides = {}, transportOverride) {
|
|
15
|
+
if (store === "app-store") {
|
|
16
|
+
const cfg = project.spec.stores.appStore;
|
|
17
|
+
if (!cfg)
|
|
18
|
+
throw new Error("The spec has no stores.appStore section.");
|
|
19
|
+
const transport = transportOverride ?? createTransport(store, { via: cfg.via, account: cfg.account, ...overrides });
|
|
20
|
+
return { store, transport, adapter: new AppStoreAdapter(transport, cfg.appId, cfg.platform) };
|
|
21
|
+
}
|
|
22
|
+
const cfg = project.spec.stores.googlePlay;
|
|
23
|
+
if (!cfg)
|
|
24
|
+
throw new Error("The spec has no stores.googlePlay section.");
|
|
25
|
+
const transport = transportOverride ?? createTransport(store, { via: cfg.via, account: cfg.account ?? cfg.profile, ...overrides });
|
|
26
|
+
return { store, transport, adapter: new GooglePlayAdapter(transport, cfg.packageName) };
|
|
27
|
+
}
|
|
28
|
+
/** Pull the store, diff it with the spec and mark what cannot be pushed yet. */
|
|
29
|
+
export async function planStore(project, handle) {
|
|
30
|
+
const state = await handle.adapter.pull();
|
|
31
|
+
const local = project.listings[handle.store];
|
|
32
|
+
const changes = handle.adapter.annotate(diffListings(handle.store, local, state.listings), state, local);
|
|
33
|
+
return { store: handle.store, via: handle.transport.label, state, changes };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=project.js.map
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Project } from "./schema.js";
|
|
2
|
+
/** Load the spec as it was at a git ref (e.g. `origin/main`), or null when it did not exist there. */
|
|
3
|
+
export declare function loadProjectAtRef(root: string, ref: string): Promise<Project | null>;
|
|
4
|
+
/** Markdown for a pull request: listing changes, check results and coverage before → after. */
|
|
5
|
+
export declare function specReport(base: Project | null, head: Project): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { projectCoverage } from "./coverage.js";
|
|
7
|
+
import { diffListings } from "./diff.js";
|
|
8
|
+
import { loadProject, SPEC_DIR, SPEC_FILE } from "./files.js";
|
|
9
|
+
import { lintProject } from "./lint.js";
|
|
10
|
+
import { STORES } from "./schema.js";
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
/** Load the spec as it was at a git ref (e.g. `origin/main`), or null when it did not exist there. */
|
|
13
|
+
export async function loadProjectAtRef(root, ref) {
|
|
14
|
+
const { stdout: top } = await run("git", ["rev-parse", "--show-toplevel"], { cwd: root });
|
|
15
|
+
const prefix = relative(await realpath(top.trim()), await realpath(root));
|
|
16
|
+
const paths = [SPEC_FILE, SPEC_DIR].map((p) => (prefix ? `${prefix}/${p}` : p));
|
|
17
|
+
const dir = await mkdtemp(join(tmpdir(), "appeeky-spec-base-"));
|
|
18
|
+
try {
|
|
19
|
+
const archive = await run("git", ["archive", "--format=tar", ref, "--", ...paths], { cwd: top.trim(), encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }).catch(() => null);
|
|
20
|
+
if (!archive)
|
|
21
|
+
return null;
|
|
22
|
+
await new Promise((resolve, reject) => {
|
|
23
|
+
const tar = execFile("tar", ["-x", "-C", dir], (err) => (err ? reject(err) : resolve()));
|
|
24
|
+
tar.stdin.end(archive.stdout);
|
|
25
|
+
});
|
|
26
|
+
return await loadProject(join(dir, prefix));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
// loadProject read everything into memory; the copy is no longer needed.
|
|
33
|
+
await rm(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const key = (r) => `${r.store}:${r.country}`;
|
|
37
|
+
const label = (r) => `${r.store === "app-store" ? "App Store" : "Google Play"} ${r.country.toUpperCase()}`;
|
|
38
|
+
/** Markdown for a pull request: listing changes, check results and coverage before → after. */
|
|
39
|
+
export function specReport(base, head) {
|
|
40
|
+
const out = ["## Appeeky Spec"];
|
|
41
|
+
const changes = STORES.flatMap((store) => diffListings(store, head.listings[store], (base?.listings[store] ?? {})));
|
|
42
|
+
out.push("", `**${changes.length} listing field${changes.length === 1 ? "" : "s"} changed**`);
|
|
43
|
+
if (changes.length > 0) {
|
|
44
|
+
out.push("", "| Store | Locale | Field | After |", "| --- | --- | --- | --- |");
|
|
45
|
+
for (const c of changes.slice(0, 40)) {
|
|
46
|
+
out.push(`| ${c.store} | ${c.locale} | ${c.field} | ${c.after.replace(/\n+/g, " ").replace(/\|/g, "\\|").slice(0, 90)} |`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const issues = lintProject(head).filter((i) => i.severity !== "info");
|
|
50
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
51
|
+
out.push("", `**Check:** ${errors} error${errors === 1 ? "" : "s"}, ${issues.length - errors} warning${issues.length - errors === 1 ? "" : "s"}`);
|
|
52
|
+
for (const i of issues.slice(0, 20))
|
|
53
|
+
out.push(`- ${i.severity === "error" ? "❌" : "⚠️"} ${i.store} ${i.locale}${i.field ? ` ${i.field}` : ""}: ${i.message}`);
|
|
54
|
+
const before = new Map((base ? projectCoverage(base) : []).map((r) => [key(r), r]));
|
|
55
|
+
const after = projectCoverage(head);
|
|
56
|
+
if (after.length > 0) {
|
|
57
|
+
out.push("", "| Storefront | Coverage | Gained | Lost |", "| --- | --- | --- | --- |");
|
|
58
|
+
for (const r of after) {
|
|
59
|
+
const b = before.get(key(r));
|
|
60
|
+
const covered = (x) => new Set((x?.keywords ?? []).filter((k) => ["title", "subtitle", "short", "full"].includes(k.level)).map((k) => k.keyword));
|
|
61
|
+
const was = covered(b);
|
|
62
|
+
const now = covered(r);
|
|
63
|
+
const gained = [...now].filter((k) => !was.has(k));
|
|
64
|
+
const lost = [...was].filter((k) => !now.has(k));
|
|
65
|
+
const pct = b ? `${b.percent}% → ${r.percent}%` : `${r.percent}%`;
|
|
66
|
+
out.push(`| ${label(r)} | ${pct} | ${gained.join(", ") || "—"} | ${lost.length > 0 ? `**${lost.join(", ")}**` : "—"} |`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out.join("\n");
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AppeekyApi } from "./appeeky-api.js";
|
|
3
|
+
import type { MetricSource } from "./metrics.js";
|
|
4
|
+
import type { StoreId } from "./schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* Customer reviews in the repo, so an agent can read what users say next to the
|
|
7
|
+
* listing, and answer them through a pull request like any other change.
|
|
8
|
+
*
|
|
9
|
+
* appeeky/reviews/<store>/<YYYY-MM>.jsonl one review per line, newest first
|
|
10
|
+
* appeeky/reviews/summary.md ratings, replies, versions, last 30 days
|
|
11
|
+
* appeeky/reviews/replies/<id>.yaml a reply: drafted, reviewed in a PR, then sent
|
|
12
|
+
*
|
|
13
|
+
* Reviews come from the store connections in Appeeky (APPEEKY_API_KEY).
|
|
14
|
+
*/
|
|
15
|
+
export interface Review {
|
|
16
|
+
id: string;
|
|
17
|
+
store: StoreId;
|
|
18
|
+
rating: number;
|
|
19
|
+
title?: string;
|
|
20
|
+
body: string;
|
|
21
|
+
author?: string;
|
|
22
|
+
/** App Store territory (US) or Google Play language (en). */
|
|
23
|
+
where?: string;
|
|
24
|
+
version?: string;
|
|
25
|
+
date: string;
|
|
26
|
+
reply?: {
|
|
27
|
+
body: string;
|
|
28
|
+
date?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare function reviewsDir(root: string): string;
|
|
32
|
+
interface AscReviewRow {
|
|
33
|
+
id: string;
|
|
34
|
+
rating: number;
|
|
35
|
+
title: string | null;
|
|
36
|
+
body: string | null;
|
|
37
|
+
reviewer_nickname: string | null;
|
|
38
|
+
territory: string | null;
|
|
39
|
+
app_version: string | null;
|
|
40
|
+
review_created_at: string;
|
|
41
|
+
response_body: string | null;
|
|
42
|
+
response_last_modified: string | null;
|
|
43
|
+
}
|
|
44
|
+
interface PlayReviewRow {
|
|
45
|
+
review_id: string;
|
|
46
|
+
author_name: string | null;
|
|
47
|
+
reviewer_language: string | null;
|
|
48
|
+
star_rating: number;
|
|
49
|
+
review_text: string | null;
|
|
50
|
+
review_last_modified_at: string;
|
|
51
|
+
app_version_name: string | null;
|
|
52
|
+
developer_reply_text: string | null;
|
|
53
|
+
developer_reply_last_modified_at: string | null;
|
|
54
|
+
}
|
|
55
|
+
export declare function fromAsc(r: AscReviewRow): Review;
|
|
56
|
+
export declare function fromPlay(r: PlayReviewRow): Review;
|
|
57
|
+
/** Newest reviews first, paging back until `since` (or `max` reviews). */
|
|
58
|
+
export declare function fetchReviews(api: AppeekyApi, store: StoreId, appId: string, options?: {
|
|
59
|
+
since?: string;
|
|
60
|
+
max?: number;
|
|
61
|
+
}): Promise<Review[]>;
|
|
62
|
+
export declare function readReviews(root: string, store?: StoreId): Promise<Review[]>;
|
|
63
|
+
/**
|
|
64
|
+
* Merge fetched reviews into the monthly files: a review that changed (edited,
|
|
65
|
+
* answered) replaces the old line. Returns how many were new and how many changed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function saveReviews(root: string, fetched: Review[]): Promise<{
|
|
68
|
+
added: number;
|
|
69
|
+
updated: number;
|
|
70
|
+
}>;
|
|
71
|
+
export interface ReviewStats {
|
|
72
|
+
count: number;
|
|
73
|
+
average: number | null;
|
|
74
|
+
/** Share of 1- and 2-star reviews. */
|
|
75
|
+
low: number | null;
|
|
76
|
+
unanswered: number;
|
|
77
|
+
distribution: Record<1 | 2 | 3 | 4 | 5, number>;
|
|
78
|
+
}
|
|
79
|
+
export declare function reviewStats(reviews: Review[]): ReviewStats;
|
|
80
|
+
/**
|
|
81
|
+
* What users ask for in their own words: words and two-word phrases that come up in three or
|
|
82
|
+
* more reviews and appear nowhere in your listing. Candidates for targets, or for the product.
|
|
83
|
+
* English stop words only, so other languages show more noise.
|
|
84
|
+
*/
|
|
85
|
+
export declare function reviewPhrases(reviews: Review[], listingText: string, limit?: number): Array<{
|
|
86
|
+
phrase: string;
|
|
87
|
+
reviews: number;
|
|
88
|
+
low: number;
|
|
89
|
+
}>;
|
|
90
|
+
export declare function summaryMarkdown(reviews: Review[], now?: Date, listingText?: string): string;
|
|
91
|
+
export declare function writeSummary(root: string, now?: Date, listingText?: string): Promise<string>;
|
|
92
|
+
export declare const replySchema: z.ZodObject<{
|
|
93
|
+
id: z.ZodString;
|
|
94
|
+
store: z.ZodEnum<["app-store", "google-play"]>;
|
|
95
|
+
/** The review being answered, for whoever reviews the pull request. */
|
|
96
|
+
review: z.ZodOptional<z.ZodString>;
|
|
97
|
+
text: z.ZodString;
|
|
98
|
+
status: z.ZodDefault<z.ZodEnum<["draft", "sent"]>>;
|
|
99
|
+
sentAt: z.ZodOptional<z.ZodString>;
|
|
100
|
+
}, "strict", z.ZodTypeAny, {
|
|
101
|
+
status: "draft" | "sent";
|
|
102
|
+
store: "app-store" | "google-play";
|
|
103
|
+
id: string;
|
|
104
|
+
text: string;
|
|
105
|
+
review?: string | undefined;
|
|
106
|
+
sentAt?: string | undefined;
|
|
107
|
+
}, {
|
|
108
|
+
store: "app-store" | "google-play";
|
|
109
|
+
id: string;
|
|
110
|
+
text: string;
|
|
111
|
+
status?: "draft" | "sent" | undefined;
|
|
112
|
+
review?: string | undefined;
|
|
113
|
+
sentAt?: string | undefined;
|
|
114
|
+
}>;
|
|
115
|
+
export type Reply = z.infer<typeof replySchema>;
|
|
116
|
+
export declare const repliesDir: (root: string) => string;
|
|
117
|
+
export declare function writeReply(root: string, reply: z.input<typeof replySchema>): Promise<string>;
|
|
118
|
+
export declare function readReplies(root: string): Promise<Array<Reply & {
|
|
119
|
+
file: string;
|
|
120
|
+
}>>;
|
|
121
|
+
/** Store limits for a developer reply. */
|
|
122
|
+
export declare const REPLY_LIMIT: Record<StoreId, number>;
|
|
123
|
+
export declare function replyIssues(reply: Reply): string[];
|
|
124
|
+
/** Send one draft reply to the store and mark it sent. */
|
|
125
|
+
export declare function sendReply(api: AppeekyApi, reply: Reply & {
|
|
126
|
+
file: string;
|
|
127
|
+
}, packageName: string | undefined, now?: Date): Promise<void>;
|
|
128
|
+
/** Ratings around a push, from the pulled review files: no API call. */
|
|
129
|
+
export declare function reviewMetricSource(root: string): MetricSource;
|
|
130
|
+
export {};
|